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