| author | |
| committer | |
| log | 7c6163c67aa122fa7c13466536c012888db102c6 |
| tree | 36602b9b41371f8f6050662c68c0b0127699b661 |
| parent | 10626c537886b41b6d9fc404521512f4f3702157 |
| signature | Signed by PGP key B5690EEEBB952194 |
24 files changed, 1096 insertions(+), 95 deletions(-)
apps/mobile/src/features/threads/PendingApprovalCard.tsx+39-25| ... | ... | @@ -1,4 +1,8 @@ |
| 1 | import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | ApprovalRequestId, | |
| 3 | ProviderApprovalDecision, | |
| 4 | ProviderApprovalOption, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | import { Pressable, View } from "react-native"; |
| 3 | 7 | |
| 4 | 8 | import { AppText as Text } from "../../components/AppText"; |
| ... | ... | @@ -13,7 +17,14 @@ export interface PendingApprovalCardProps { |
| 13 | 17 | ) => Promise<unknown>; |
| 14 | 18 | } |
| 15 | 19 | |
| 20 | const 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 | ||
| 16 | 26 | export function PendingApprovalCard(props: PendingApprovalCardProps) { |
| 27 | const options = props.approval.options ?? DEFAULT_APPROVAL_OPTIONS; | |
| 17 | 28 | // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed |
| 18 | 29 | // behind this card, so a translucent surface bleeds messages through it. |
| 19 | 30 | return ( |
| ... | ... | @@ -22,7 +33,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { |
| 22 | 33 | Approval needed |
| 23 | 34 | </Text> |
| 24 | 35 | <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} | |
| 26 | 37 | </Text> |
| 27 | 38 | {props.approval.detail ? ( |
| 28 | 39 | <Text className="font-sans text-sm leading-normal text-neutral-600 dark:text-neutral-400"> |
| ... | ... | @@ -30,29 +41,32 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { |
| 30 | 41 | </Text> |
| 31 | 42 | ) : null} |
| 32 | 43 | <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 | ))} | |
| 56 | 70 | </View> |
| 57 | 71 | </View> |
| 58 | 72 | ); |
apps/mobile/src/lib/threadActivity.test.ts+54| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | import { |
| 16 | 16 | buildPendingUserInputAnswers, |
| 17 | 17 | buildThreadFeed, |
| 18 | derivePendingApprovals, | |
| 18 | 19 | deriveThreadFeedPresentation, |
| 19 | 20 | isPendingUserInputOptionSelected, |
| 20 | 21 | setPendingUserInputCustomAnswer, |
| ... | ... | @@ -142,6 +143,59 @@ describe("pending user input answers", () => { |
| 142 | 143 | }); |
| 143 | 144 | }); |
| 144 | 145 | |
| 146 | describe("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 | ||
| 145 | 199 | function makeActivity( |
| 146 | 200 | input: Partial<OrchestrationThreadActivity> & |
| 147 | 201 | Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">, |
apps/mobile/src/lib/threadActivity.ts+24-8| ... | ... | @@ -1,4 +1,9 @@ |
| 1 | import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | ApprovalRequestId, | |
| 3 | isToolLifecycleItemType, | |
| 4 | ProviderApprovalOption, | |
| 5 | ProviderRequestKind, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | import type { |
| 3 | 8 | OrchestrationLatestTurn, |
| 4 | 9 | OrchestrationThread, |
| ... | ... | @@ -11,14 +16,20 @@ import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 11 | 16 | |
| 12 | 17 | import * as Arr from "effect/Array"; |
| 13 | 18 | import * as Order from "effect/Order"; |
| 19 | import * as Schema from "effect/Schema"; | |
| 14 | 20 | |
| 15 | 21 | export interface PendingApproval { |
| 16 | 22 | readonly requestId: ApprovalRequestId; |
| 17 | readonly requestKind: "command" | "file-read" | "file-change"; | |
| 23 | readonly requestKind: ProviderRequestKind; | |
| 18 | 24 | readonly createdAt: string; |
| 19 | 25 | readonly detail?: string; |
| 26 | readonly appName?: string; | |
| 27 | readonly options?: ReadonlyArray<ProviderApprovalOption>; | |
| 20 | 28 | } |
| 21 | 29 | |
| 30 | const isProviderRequestKind = Schema.is(ProviderRequestKind); | |
| 31 | const isProviderApprovalOption = Schema.is(ProviderApprovalOption); | |
| 32 | ||
| 22 | 33 | export interface PendingUserInput { |
| 23 | 34 | readonly requestId: ApprovalRequestId; |
| 24 | 35 | readonly createdAt: string; |
| ... | ... | @@ -147,6 +158,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ |
| 147 | 158 | case "file_change_approval": |
| 148 | 159 | case "apply_patch_approval": |
| 149 | 160 | return "file-change"; |
| 161 | case "mcp_elicitation_approval": | |
| 162 | return "mcp-elicitation"; | |
| 150 | 163 | default: |
| 151 | 164 | return null; |
| 152 | 165 | } |
| ... | ... | @@ -1375,13 +1388,14 @@ export function derivePendingApprovals( |
| 1375 | 1388 | ? (activity.payload as Record<string, unknown>) |
| 1376 | 1389 | : null; |
| 1377 | 1390 | 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); | |
| 1384 | 1394 | 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; | |
| 1385 | 1399 | |
| 1386 | 1400 | if (activity.kind === "approval.requested" && requestId && requestKind) { |
| 1387 | 1401 | openByRequestId.set(requestId, { |
| ... | ... | @@ -1389,6 +1403,8 @@ export function derivePendingApprovals( |
| 1389 | 1403 | requestKind, |
| 1390 | 1404 | createdAt: activity.createdAt, |
| 1391 | 1405 | ...(detail ? { detail } : {}), |
| 1406 | ...(appName ? { appName } : {}), | |
| 1407 | ...(options && options.length > 0 ? { options } : {}), | |
| 1392 | 1408 | }); |
| 1393 | 1409 | continue; |
| 1394 | 1410 | } |
apps/server/integration/OrchestrationEngineHarness.integration.ts+5-4| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | ProviderDriverKind, |
| 9 | 9 | type OrchestrationEvent, |
| 10 | 10 | type OrchestrationThread, |
| 11 | type ProviderApprovalDecision, | |
| 11 | 12 | } from "@t3tools/contracts"; |
| 12 | 13 | import * as Effect from "effect/Effect"; |
| 13 | 14 | import * as Exit from "effect/Exit"; |
| ... | ... | @@ -199,14 +200,14 @@ export interface OrchestrationIntegrationHarness { |
| 199 | 200 | requestId: string, |
| 200 | 201 | predicate: (row: { |
| 201 | 202 | readonly status: "pending" | "resolved"; |
| 202 | readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; | |
| 203 | readonly decision: ProviderApprovalDecision | null; | |
| 203 | 204 | readonly resolvedAt: string | null; |
| 204 | 205 | }) => boolean, |
| 205 | 206 | timeoutMs?: number, |
| 206 | 207 | ) => Effect.Effect< |
| 207 | 208 | { |
| 208 | 209 | readonly status: "pending" | "resolved"; |
| 209 | readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; | |
| 210 | readonly decision: ProviderApprovalDecision | null; | |
| 210 | 211 | readonly resolvedAt: string | null; |
| 211 | 212 | }, |
| 212 | 213 | never |
| ... | ... | @@ -493,7 +494,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 493 | 494 | row, |
| 494 | 495 | ): row is { |
| 495 | 496 | readonly status: "pending" | "resolved"; |
| 496 | readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; | |
| 497 | readonly decision: ProviderApprovalDecision | null; | |
| 497 | 498 | readonly resolvedAt: string | null; |
| 498 | 499 | } => row !== null && predicate(row), |
| 499 | 500 | `pending approval '${requestId}'`, |
| ... | ... | @@ -501,7 +502,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 501 | 502 | ) as Effect.Effect< |
| 502 | 503 | { |
| 503 | 504 | readonly status: "pending" | "resolved"; |
| 504 | readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; | |
| 505 | readonly decision: ProviderApprovalDecision | null; | |
| 505 | 506 | readonly resolvedAt: string | null; |
| 506 | 507 | }, |
| 507 | 508 | never |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+1| ... | ... | @@ -1507,6 +1507,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1507 | 1507 | const resolvedDecision = |
| 1508 | 1508 | resolvedDecisionRaw === "accept" || |
| 1509 | 1509 | resolvedDecisionRaw === "acceptForSession" || |
| 1510 | resolvedDecisionRaw === "acceptAlways" || | |
| 1510 | 1511 | resolvedDecisionRaw === "decline" || |
| 1511 | 1512 | resolvedDecisionRaw === "cancel" |
| 1512 | 1513 | ? resolvedDecisionRaw |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts+37| ... | ... | @@ -30,4 +30,41 @@ describe("runtimeEventToActivities approval details", () => { |
| 30 | 30 | expect(activity?.kind).toBe("approval.requested"); |
| 31 | 31 | expect((activity?.payload as Record<string, unknown> | undefined)?.detail).toBe(detail); |
| 32 | 32 | }); |
| 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 | }); | |
| 33 | 70 | }); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+8-2| ... | ... | @@ -298,7 +298,7 @@ function sessionStatusAllowsActiveTurn( |
| 298 | 298 | |
| 299 | 299 | function requestKindFromCanonicalRequestType( |
| 300 | 300 | requestType: string | undefined, |
| 301 | ): "command" | "file-read" | "file-change" | undefined { | |
| 301 | ): "command" | "file-read" | "file-change" | "mcp-elicitation" | undefined { | |
| 302 | 302 | switch (requestType) { |
| 303 | 303 | case "command_execution_approval": |
| 304 | 304 | case "exec_command_approval": |
| ... | ... | @@ -308,6 +308,8 @@ function requestKindFromCanonicalRequestType( |
| 308 | 308 | case "file_change_approval": |
| 309 | 309 | case "apply_patch_approval": |
| 310 | 310 | return "file-change"; |
| 311 | case "mcp_elicitation_approval": | |
| 312 | return "mcp-elicitation"; | |
| 311 | 313 | default: |
| 312 | 314 | return undefined; |
| 313 | 315 | } |
| ... | ... | @@ -388,12 +390,16 @@ export function runtimeEventToActivities( |
| 388 | 390 | ? "File-read approval requested" |
| 389 | 391 | : requestKind === "file-change" |
| 390 | 392 | ? "File-change approval requested" |
| 391 | : "Approval requested", | |
| 393 | : requestKind === "mcp-elicitation" | |
| 394 | ? "App access approval requested" | |
| 395 | : "Approval requested", | |
| 392 | 396 | payload: { |
| 393 | 397 | requestId: toApprovalRequestId(event.requestId), |
| 394 | 398 | ...(requestKind ? { requestKind } : {}), |
| 395 | 399 | requestType: event.payload.requestType, |
| 396 | 400 | ...(event.payload.detail ? { detail: event.payload.detail } : {}), |
| 401 | ...(event.payload.appName ? { appName: event.payload.appName } : {}), | |
| 402 | ...(event.payload.options ? { options: event.payload.options } : {}), | |
| 397 | 403 | }, |
| 398 | 404 | turnId: toTurnId(event.turnId) ?? null, |
| 399 | 405 | ...maybeSequence, |
apps/server/src/provider/Layers/CodexAdapter.test.ts+73| ... | ... | @@ -1066,6 +1066,79 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 1066 | 1066 | }), |
| 1067 | 1067 | ); |
| 1068 | 1068 | |
| 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 | ||
| 1069 | 1142 | it.effect("preserves file-read request type when mapping serverRequest/resolved", () => |
| 1070 | 1143 | Effect.gen(function* () { |
| 1071 | 1144 | const { adapter, runtime } = yield* startLifecycleRuntime(); |
apps/server/src/provider/Layers/CodexAdapter.ts+18| ... | ... | @@ -57,6 +57,7 @@ import { ServerConfig } from "../../config.ts"; |
| 57 | 57 | import { |
| 58 | 58 | CodexResumeCursorSchema, |
| 59 | 59 | CodexSessionRuntimeThreadIdMissingError, |
| 60 | describeMcpElicitation, | |
| 60 | 61 | makeCodexSessionRuntime, |
| 61 | 62 | type CodexSessionRuntimeError, |
| 62 | 63 | type CodexSessionRuntimeOptions, |
| ... | ... | @@ -302,6 +303,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType { |
| 302 | 303 | return "file_read_approval"; |
| 303 | 304 | case "item/fileChange/requestApproval": |
| 304 | 305 | return "file_change_approval"; |
| 306 | case "mcpServer/elicitation/request": | |
| 307 | return "mcp_elicitation_approval"; | |
| 305 | 308 | case "applyPatchApproval": |
| 306 | 309 | return "apply_patch_approval"; |
| 307 | 310 | case "execCommandApproval": |
| ... | ... | @@ -325,6 +328,8 @@ function toRequestTypeFromKind(kind: ProviderRequestKind | undefined): Canonical |
| 325 | 328 | return "file_read_approval"; |
| 326 | 329 | case "file-change": |
| 327 | 330 | return "file_change_approval"; |
| 331 | case "mcp-elicitation": | |
| 332 | return "mcp_elicitation_approval"; | |
| 328 | 333 | default: |
| 329 | 334 | return "unknown"; |
| 330 | 335 | } |
| ... | ... | @@ -800,6 +805,11 @@ function mapToRuntimeEvents( |
| 800 | 805 | ]; |
| 801 | 806 | } |
| 802 | 807 | |
| 808 | const elicitation = | |
| 809 | event.method === "mcpServer/elicitation/request" | |
| 810 | ? readPayload(EffectCodexSchema.McpServerElicitationRequestParams, event.payload) | |
| 811 | : undefined; | |
| 812 | const elicitationApproval = elicitation ? describeMcpElicitation(elicitation) : undefined; | |
| 803 | 813 | const detail = (() => { |
| 804 | 814 | switch (event.method) { |
| 805 | 815 | case "item/commandExecution/requestApproval": { |
| ... | ... | @@ -816,6 +826,8 @@ function mapToRuntimeEvents( |
| 816 | 826 | ); |
| 817 | 827 | return payload?.reason ?? undefined; |
| 818 | 828 | } |
| 829 | case "mcpServer/elicitation/request": | |
| 830 | return elicitation?.message; | |
| 819 | 831 | case "applyPatchApproval": { |
| 820 | 832 | const payload = readPayload( |
| 821 | 833 | EffectCodexSchema.ServerRequest__ApplyPatchApprovalParams, |
| ... | ... | @@ -849,6 +861,12 @@ function mapToRuntimeEvents( |
| 849 | 861 | payload: { |
| 850 | 862 | requestType: toRequestTypeFromMethod(event.method), |
| 851 | 863 | ...(detail ? { detail } : {}), |
| 864 | ...(elicitationApproval | |
| 865 | ? { | |
| 866 | appName: elicitationApproval.appName, | |
| 867 | options: elicitationApproval.options, | |
| 868 | } | |
| 869 | : {}), | |
| 852 | 870 | ...(event.payload !== undefined ? { args: event.payload } : {}), |
| 853 | 871 | }, |
| 854 | 872 | }, |
apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts+123-1| ... | ... | @@ -13,9 +13,11 @@ import * as NodePath from "node:path"; |
| 13 | 13 | |
| 14 | 14 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 15 | 15 | import { it } from "@effect/vitest"; |
| 16 | import { ThreadId } from "@t3tools/contracts"; | |
| 16 | import { type ProviderApprovalDecision, type ProviderEvent, ThreadId } from "@t3tools/contracts"; | |
| 17 | import * as Deferred from "effect/Deferred"; | |
| 17 | 18 | import * as Effect from "effect/Effect"; |
| 18 | 19 | import * as Fiber from "effect/Fiber"; |
| 20 | import * as Schema from "effect/Schema"; | |
| 19 | 21 | import * as Stream from "effect/Stream"; |
| 20 | 22 | import { assert, describe } from "vite-plus/test"; |
| 21 | 23 | |
| ... | ... | @@ -25,6 +27,14 @@ import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; |
| 25 | 27 | const ROOT = wireFixture.rootThreadId; |
| 26 | 28 | const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; |
| 27 | 29 | const MEMORY = "memory-consolidation-thread"; |
| 30 | const decodeMcpElicitationResponse = Schema.decodeUnknownEffect( | |
| 31 | Schema.fromJsonString( | |
| 32 | Schema.Struct({ | |
| 33 | id: Schema.Number, | |
| 34 | result: Schema.Unknown, | |
| 35 | }), | |
| 36 | ), | |
| 37 | ); | |
| 28 | 38 | |
| 29 | 39 | /** |
| 30 | 40 | * The captured sequence, extended with the shapes the live capture didn't |
| ... | ... | @@ -328,4 +338,116 @@ describe("CodexSessionRuntime collab integration", () => { |
| 328 | 338 | yield* runtime.close; |
| 329 | 339 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 330 | 340 | ); |
| 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 | } | |
| 331 | 453 | }); |
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+204| ... | ... | @@ -17,10 +17,12 @@ import { |
| 17 | 17 | import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; |
| 18 | 18 | import { |
| 19 | 19 | buildTurnStartParams, |
| 20 | describeMcpElicitation, | |
| 20 | 21 | hasConfiguredMcpServer, |
| 21 | 22 | isRecoverableThreadResumeError, |
| 22 | 23 | makeMemoryConsolidationNotificationFilter, |
| 23 | 24 | openCodexThread, |
| 25 | toMcpElicitationResponse, | |
| 24 | 26 | } from "./CodexSessionRuntime.ts"; |
| 25 | 27 | const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); |
| 26 | 28 | |
| ... | ... | @@ -248,6 +250,208 @@ describe("buildTurnStartParams", () => { |
| 248 | 250 | }); |
| 249 | 251 | }); |
| 250 | 252 | |
| 253 | describe("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 | ||
| 251 | 455 | describe("buildCodexDeveloperInstructions", () => { |
| 252 | 456 | it("appends runtime info after the mode instructions", () => { |
| 253 | 457 | const instructions = buildCodexDeveloperInstructions("default", { |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+286-2| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | ProviderItemId, |
| 7 | 7 | type ProviderInstanceId, |
| 8 | 8 | type ProviderApprovalDecision, |
| 9 | type ProviderApprovalOption, | |
| 9 | 10 | type ProviderEvent, |
| 10 | 11 | type ProviderInteractionMode, |
| 11 | 12 | type ProviderRequestKind, |
| ... | ... | @@ -73,6 +74,58 @@ const CodexUserInputAnswerObject = Schema.Struct({ |
| 73 | 74 | }); |
| 74 | 75 | const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema); |
| 75 | 76 | const isCodexUserInputAnswerObject = Schema.is(CodexUserInputAnswerObject); |
| 77 | const NullableMcpElicitationString = Schema.NullOr(Schema.String); | |
| 78 | const 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 | }); | |
| 105 | const 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 | }); | |
| 123 | const McpElicitationForm = Schema.Struct({ | |
| 124 | properties: Schema.optionalKey(Schema.Record(Schema.String, McpElicitationFormField)), | |
| 125 | required: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), | |
| 126 | }); | |
| 127 | const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata); | |
| 128 | const isMcpElicitationForm = Schema.is(McpElicitationForm); | |
| 76 | 129 | |
| 77 | 130 | // TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated |
| 78 | 131 | // `V2TurnStartParams` schema includes `collaborationMode` directly. |
| ... | ... | @@ -231,6 +284,172 @@ interface PendingUserInput { |
| 231 | 284 | readonly answers: Deferred.Deferred<ProviderUserInputAnswers>; |
| 232 | 285 | } |
| 233 | 286 | |
| 287 | type McpElicitationPersistenceDecision = Extract< | |
| 288 | ProviderApprovalDecision, | |
| 289 | "acceptForSession" | "acceptAlways" | |
| 290 | >; | |
| 291 | ||
| 292 | function 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 | ||
| 308 | function mcpElicitationFormFields(payload: EffectCodexSchema.McpServerElicitationRequestParams) { | |
| 309 | if (payload.mode === "url" || !isMcpElicitationForm(payload.requestedSchema)) { | |
| 310 | return undefined; | |
| 311 | } | |
| 312 | return payload.requestedSchema; | |
| 313 | } | |
| 314 | ||
| 315 | function 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 | ||
| 325 | function 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. */ | |
| 338 | export 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. */ | |
| 404 | export 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 | ||
| 234 | 453 | type CodexServerNotification = { |
| 235 | 454 | readonly [M in CodexRpc.ServerNotificationMethod]: { |
| 236 | 455 | readonly method: M; |
| ... | ... | @@ -1544,7 +1763,7 @@ export const makeCodexSessionRuntime = ( |
| 1544 | 1763 | ), |
| 1545 | 1764 | ); |
| 1546 | 1765 | return { |
| 1547 | decision: resolved, | |
| 1766 | decision: resolved === "acceptAlways" ? "acceptForSession" : resolved, | |
| 1548 | 1767 | } satisfies EffectCodexSchema.CommandExecutionRequestApprovalResponse; |
| 1549 | 1768 | }), |
| 1550 | 1769 | ); |
| ... | ... | @@ -1602,11 +1821,76 @@ export const makeCodexSessionRuntime = ( |
| 1602 | 1821 | ), |
| 1603 | 1822 | ); |
| 1604 | 1823 | return { |
| 1605 | decision: resolved, | |
| 1824 | decision: resolved === "acceptAlways" ? "acceptForSession" : resolved, | |
| 1606 | 1825 | } satisfies EffectCodexSchema.FileChangeRequestApprovalResponse; |
| 1607 | 1826 | }), |
| 1608 | 1827 | ); |
| 1609 | 1828 | |
| 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 | ||
| 1610 | 1894 | yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => |
| 1611 | 1895 | Effect.gen(function* () { |
| 1612 | 1896 | 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 |
| 17 | 17 | |
| 18 | 18 | const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); |
| 19 | 19 | let turnStartCount = 0; |
| 20 | let activeTurn; | |
| 20 | 21 | |
| 21 | 22 | const rl = NodeReadline.createInterface({ input: process.stdin }); |
| 22 | 23 | rl.on("line", (line) => { |
| ... | ... | @@ -27,6 +28,23 @@ rl.on("line", (line) => { |
| 27 | 28 | return; |
| 28 | 29 | } |
| 29 | 30 | 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 | } | |
| 30 | 48 | if (method === "initialize") { |
| 31 | 49 | write({ |
| 32 | 50 | id, |
| ... | ... | @@ -48,6 +66,7 @@ rl.on("line", (line) => { |
| 48 | 66 | const turn = turnId |
| 49 | 67 | ? { ...fixture.responses.turnStart.turn, id: turnId } |
| 50 | 68 | : fixture.responses.turnStart.turn; |
| 69 | activeTurn = turn; | |
| 51 | 70 | turnStartCount += 1; |
| 52 | 71 | write({ id, result: { ...fixture.responses.turnStart, turn } }); |
| 53 | 72 | const rootThreadId = script.rootThreadId; |
| ... | ... | @@ -61,6 +80,9 @@ rl.on("line", (line) => { |
| 61 | 80 | for (const notification of script.notifications) { |
| 62 | 81 | write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); |
| 63 | 82 | } |
| 83 | for (const request of script.serverRequests ?? []) { | |
| 84 | write({ jsonrpc: "2.0", id: request.id, method: request.method, params: request.params }); | |
| 85 | } | |
| 64 | 86 | if (script.holdTurnOpen !== true) { |
| 65 | 87 | write({ |
| 66 | 88 | jsonrpc: "2.0", |
apps/web/src/components/chat/ChatComposer.tsx+2| ... | ... | @@ -2872,6 +2872,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2872 | 2872 | <ComposerPendingApprovalActions |
| 2873 | 2873 | requestId={activePendingApproval.requestId} |
| 2874 | 2874 | isResponding={respondingRequestIds.includes(activePendingApproval.requestId)} |
| 2875 | options={activePendingApproval.options} | |
| 2875 | 2876 | onRespondToApproval={onRespondToApproval} |
| 2876 | 2877 | /> |
| 2877 | 2878 | </div> |
| ... | ... | @@ -2901,6 +2902,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2901 | 2902 | <ComposerPendingApprovalActions |
| 2902 | 2903 | requestId={activePendingApproval.requestId} |
| 2903 | 2904 | isResponding={respondingRequestIds.includes(activePendingApproval.requestId)} |
| 2905 | options={activePendingApproval.options} | |
| 2904 | 2906 | onRespondToApproval={onRespondToApproval} |
| 2905 | 2907 | /> |
| 2906 | 2908 | </div> |
apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx+34| ... | ... | @@ -21,4 +21,38 @@ describe("ComposerPendingApprovalActions", () => { |
| 21 | 21 | expect(markup).toContain("sm:text-[11px]"); |
| 22 | 22 | expect(markup).not.toContain("sm:h-6"); |
| 23 | 23 | }); |
| 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 | }); | |
| 24 | 58 | }); |
apps/web/src/components/chat/ComposerPendingApprovalActions.tsx+31-37| ... | ... | @@ -1,10 +1,15 @@ |
| 1 | import { type ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | type ApprovalRequestId, | |
| 3 | type ProviderApprovalDecision, | |
| 4 | type ProviderApprovalOption, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | import { memo } from "react"; |
| 3 | 7 | import { Button } from "../ui/button"; |
| 4 | 8 | |
| 5 | 9 | interface ComposerPendingApprovalActionsProps { |
| 6 | 10 | requestId: ApprovalRequestId; |
| 7 | 11 | isResponding: boolean; |
| 12 | options?: ReadonlyArray<ProviderApprovalOption> | undefined; | |
| 8 | 13 | onRespondToApproval: ( |
| 9 | 14 | requestId: ApprovalRequestId, |
| 10 | 15 | decision: ProviderApprovalDecision, |
| ... | ... | @@ -12,50 +17,39 @@ interface ComposerPendingApprovalActionsProps { |
| 12 | 17 | } |
| 13 | 18 | |
| 14 | 19 | const APPROVAL_ACTION_CLASS_NAME = "font-normal"; |
| 20 | const 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>; | |
| 15 | 26 | |
| 16 | 27 | export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ |
| 17 | 28 | requestId, |
| 18 | 29 | isResponding, |
| 30 | options = DEFAULT_APPROVAL_OPTIONS, | |
| 19 | 31 | onRespondToApproval, |
| 20 | 32 | }: ComposerPendingApprovalActionsProps) { |
| 21 | 33 | return ( |
| 22 | 34 | <> |
| 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 | ))} | |
| 59 | 53 | </> |
| 60 | 54 | ); |
| 61 | 55 | }); |
apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx+42| ... | ... | @@ -50,4 +50,46 @@ describe("ComposerPendingApprovalPanel", () => { |
| 50 | 50 | |
| 51 | 51 | expect(markup).toContain("File read approval"); |
| 52 | 52 | }); |
| 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 | }); | |
| 53 | 95 | }); |
apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx+19-10| ... | ... | @@ -14,17 +14,21 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova |
| 14 | 14 | className, |
| 15 | 15 | }: ComposerPendingApprovalPanelProps) { |
| 16 | 16 | 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"; | |
| 22 | 24 | 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"; | |
| 28 | 32 | |
| 29 | 33 | return ( |
| 30 | 34 | <div |
| ... | ... | @@ -32,6 +36,11 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova |
| 32 | 36 | className={cn("flex min-w-0 flex-1 items-center gap-2", className)} |
| 33 | 37 | role="group" |
| 34 | 38 | > |
| 39 | {approval.appName ? ( | |
| 40 | <span className="max-w-32 shrink truncate text-[11px] font-medium text-foreground"> | |
| 41 | {approval.appName} | |
| 42 | </span> | |
| 43 | ) : null} | |
| 35 | 44 | <code |
| 36 | 45 | aria-label={detailAriaLabel} |
| 37 | 46 | 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", () => { |
| 130 | 130 | ]); |
| 131 | 131 | }); |
| 132 | 132 | |
| 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 | ||
| 133 | 166 | it("derives dynamic tool requests as actionable generic approvals", () => { |
| 134 | 167 | const activities: OrchestrationThreadActivity[] = [ |
| 135 | 168 | makeActivity({ |
apps/web/src/session-logic.ts+18-5| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import * as Option from "effect/Option"; |
| 2 | 2 | import * as Arr from "effect/Array"; |
| 3 | import * as Schema from "effect/Schema"; | |
| 3 | 4 | import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 4 | 5 | import { |
| 5 | 6 | ApprovalRequestId, |
| ... | ... | @@ -8,6 +9,8 @@ import { |
| 8 | 9 | type OrchestrationThreadActivity, |
| 9 | 10 | type OrchestrationProposedPlanId, |
| 10 | 11 | ProviderDriverKind, |
| 12 | ProviderApprovalOption, | |
| 13 | ProviderRequestKind, | |
| 11 | 14 | type ToolLifecycleItemType, |
| 12 | 15 | type UserInputQuestion, |
| 13 | 16 | type ThreadId, |
| ... | ... | @@ -116,11 +119,16 @@ const derivedWorkLogEntryByActivity = new WeakMap< |
| 116 | 119 | |
| 117 | 120 | export interface PendingApproval { |
| 118 | 121 | requestId: ApprovalRequestId; |
| 119 | requestKind: "command" | "file-read" | "file-change"; | |
| 122 | requestKind: ProviderRequestKind; | |
| 120 | 123 | createdAt: string; |
| 121 | 124 | detail?: string; |
| 125 | appName?: string; | |
| 126 | options?: ReadonlyArray<ProviderApprovalOption>; | |
| 122 | 127 | } |
| 123 | 128 | |
| 129 | const isProviderRequestKind = Schema.is(ProviderRequestKind); | |
| 130 | const isProviderApprovalOption = Schema.is(ProviderApprovalOption); | |
| 131 | ||
| 124 | 132 | export interface PendingUserInput { |
| 125 | 133 | requestId: ApprovalRequestId; |
| 126 | 134 | createdAt: string; |
| ... | ... | @@ -385,6 +393,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ |
| 385 | 393 | case "file_change_approval": |
| 386 | 394 | case "apply_patch_approval": |
| 387 | 395 | return "file-change"; |
| 396 | case "mcp_elicitation_approval": | |
| 397 | return "mcp-elicitation"; | |
| 388 | 398 | default: |
| 389 | 399 | return null; |
| 390 | 400 | } |
| ... | ... | @@ -422,15 +432,16 @@ export function derivePendingApprovals( |
| 422 | 432 | ? ApprovalRequestId.make(payload.requestId) |
| 423 | 433 | : null; |
| 424 | 434 | const requestKind = |
| 425 | payload && | |
| 426 | (payload.requestKind === "command" || | |
| 427 | payload.requestKind === "file-read" || | |
| 428 | payload.requestKind === "file-change") | |
| 435 | payload && isProviderRequestKind(payload.requestKind) | |
| 429 | 436 | ? payload.requestKind |
| 430 | 437 | : payload |
| 431 | 438 | ? requestKindFromRequestType(payload.requestType) |
| 432 | 439 | : null; |
| 433 | 440 | 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; | |
| 434 | 445 | |
| 435 | 446 | if (activity.kind === "approval.requested" && requestId && requestKind) { |
| 436 | 447 | openByRequestId.set(requestId, { |
| ... | ... | @@ -438,6 +449,8 @@ export function derivePendingApprovals( |
| 438 | 449 | requestKind, |
| 439 | 450 | createdAt: activity.createdAt, |
| 440 | 451 | ...(detail ? { detail } : {}), |
| 452 | ...(appName ? { appName } : {}), | |
| 453 | ...(options && options.length > 0 ? { options } : {}), | |
| 441 | 454 | }); |
| 442 | 455 | continue; |
| 443 | 456 | } |
docs/user/providers-codex.md+6| ... | ... | @@ -34,6 +34,12 @@ In an existing Codex thread, send `/feedback` or `/feedback` followed by a descr |
| 34 | 34 | issue. T3 Code uploads the thread and Codex logs to OpenAI and shows a thread ID that you can copy |
| 35 | 35 | and share with OpenAI employees. |
| 36 | 36 | |
| 37 | ## Approve access to other apps | |
| 38 | ||
| 39 | When a Codex tool needs access to an app such as Safari, T3 Code shows the app name and asks for | |
| 40 | approval. You can approve, decline, or cancel the request from the desktop app, web app, or mobile | |
| 41 | app. Some tools also offer approval for the current session or permanent approval. | |
| 42 | ||
| 37 | 43 | ## I Want Work And Personal Codex Accounts |
| 38 | 44 | |
| 39 | 45 | Use 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"; |
| 128 | 128 | export const ProviderInteractionMode = Schema.Literals(["default", "plan"]); |
| 129 | 129 | export type ProviderInteractionMode = typeof ProviderInteractionMode.Type; |
| 130 | 130 | export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default"; |
| 131 | export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]); | |
| 131 | export const ProviderRequestKind = Schema.Literals([ | |
| 132 | "command", | |
| 133 | "file-read", | |
| 134 | "file-change", | |
| 135 | "mcp-elicitation", | |
| 136 | ]); | |
| 132 | 137 | export type ProviderRequestKind = typeof ProviderRequestKind.Type; |
| 133 | 138 | export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); |
| 134 | 139 | export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type; |
| 135 | 140 | export const ProviderApprovalDecision = Schema.Literals([ |
| 136 | 141 | "accept", |
| 137 | 142 | "acceptForSession", |
| 143 | "acceptAlways", | |
| 138 | 144 | "decline", |
| 139 | 145 | "cancel", |
| 140 | 146 | ]); |
| 141 | 147 | export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type; |
| 148 | export const ProviderApprovalOption = Schema.Struct({ | |
| 149 | decision: ProviderApprovalDecision, | |
| 150 | label: TrimmedNonEmptyString, | |
| 151 | }); | |
| 152 | export type ProviderApprovalOption = typeof ProviderApprovalOption.Type; | |
| 142 | 153 | export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown); |
| 143 | 154 | export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; |
| 144 | 155 |
packages/contracts/src/providerRuntime.ts+4| ... | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | TurnId, |
| 15 | 15 | } from "./baseSchemas.ts"; |
| 16 | 16 | import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts"; |
| 17 | import { ProviderApprovalOption } from "./orchestration.ts"; | |
| 17 | 18 | |
| 18 | 19 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 19 | 20 | const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); |
| ... | ... | @@ -138,6 +139,7 @@ export const CanonicalRequestType = Schema.Literals([ |
| 138 | 139 | "file_change_approval", |
| 139 | 140 | "apply_patch_approval", |
| 140 | 141 | "exec_command_approval", |
| 142 | "mcp_elicitation_approval", | |
| 141 | 143 | "tool_user_input", |
| 142 | 144 | "dynamic_tool_call", |
| 143 | 145 | "auth_tokens_refresh", |
| ... | ... | @@ -430,6 +432,8 @@ export type ContentDeltaPayload = typeof ContentDeltaPayload.Type; |
| 430 | 432 | const RequestOpenedPayload = Schema.Struct({ |
| 431 | 433 | requestType: CanonicalRequestType, |
| 432 | 434 | detail: Schema.optional(TrimmedNonEmptyStringSchema), |
| 435 | appName: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 436 | options: Schema.optional(Schema.Array(ProviderApprovalOption)), | |
| 433 | 437 | args: Schema.optional(Schema.Unknown), |
| 434 | 438 | }); |
| 435 | 439 | export type RequestOpenedPayload = typeof RequestOpenedPayload.Type; |
packages/effect-codex-app-server/src/errors.ts+1| ... | ... | @@ -118,6 +118,7 @@ export const CodexAppServerIdentifierPurpose = Schema.Literals([ |
| 118 | 118 | "provider-event", |
| 119 | 119 | "command-approval-request", |
| 120 | 120 | "file-change-approval-request", |
| 121 | "mcp-elicitation-request", | |
| 121 | 122 | "user-input-request", |
| 122 | 123 | ]); |
| 123 | 124 | export type CodexAppServerIdentifierPurpose = typeof CodexAppServerIdentifierPurpose.Type; |