| author | |
| committer | |
| log | efb96939fbb135b9f4de3bff19447200b178b23a |
| tree | 673649930de9e967cf3d857abe21baf2cd56b20e |
| parent | 82059df1523d070b00ab6f45159115f626a4977e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>15 files changed, 308 insertions(+), 9 deletions(-)
apps/mobile/src/components/AppSymbol.tsx+2| ... | @@ -64,6 +64,7 @@ import IconHome from "@tabler/icons-react-native/IconHome"; | ... | @@ -64,6 +64,7 @@ import IconHome from "@tabler/icons-react-native/IconHome"; |
| 64 | import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle"; | 64 | import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle"; |
| 65 | import IconKeyboard from "@tabler/icons-react-native/IconKeyboard"; | 65 | import IconKeyboard from "@tabler/icons-react-native/IconKeyboard"; |
| 66 | import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide"; | 66 | import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide"; |
| 67 | import IconLock from "@tabler/icons-react-native/IconLock"; | ||
| 67 | import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns"; | 68 | import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns"; |
| 68 | import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar"; | 69 | import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar"; |
| 69 | import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight"; | 70 | import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight"; |
| ... | @@ -240,6 +241,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME = { | ... | @@ -240,6 +241,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME = { |
| 240 | keyboard_arrow_down: IconChevronDown, | 241 | keyboard_arrow_down: IconChevronDown, |
| 241 | keyboard_arrow_up: IconChevronUp, | 242 | keyboard_arrow_up: IconChevronUp, |
| 242 | keyboard_hide: IconKeyboardHide, | 243 | keyboard_hide: IconKeyboardHide, |
| 244 | lock: IconLock, | ||
| 243 | more_vert: IconDotsVertical, | 245 | more_vert: IconDotsVertical, |
| 244 | merge: IconGitMerge, | 246 | merge: IconGitMerge, |
| 245 | public: IconWorld, | 247 | public: IconWorld, |
apps/mobile/src/features/threads/thread-work-log.tsx+2| ... | @@ -363,6 +363,8 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { | ... | @@ -363,6 +363,8 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { |
| 363 | return { ios: "globe", android: "public" }; | 363 | return { ios: "globe", android: "public" }; |
| 364 | case "hammer": | 364 | case "hammer": |
| 365 | return { ios: "hammer", android: "construction" }; | 365 | return { ios: "hammer", android: "construction" }; |
| 366 | case "lock": | ||
| 367 | return { ios: "lock", android: "lock" }; | ||
| 366 | case "message": | 368 | case "message": |
| 367 | return { ios: "bubble.left", android: "chat_bubble" }; | 369 | return { ios: "bubble.left", android: "chat_bubble" }; |
| 368 | case "warning": | 370 | case "warning": |
apps/mobile/src/lib/threadActivity.ts+4-1| ... | @@ -68,6 +68,7 @@ export interface ThreadFeedActivity { | ... | @@ -68,6 +68,7 @@ export interface ThreadFeedActivity { |
| 68 | | "eye" | 68 | | "eye" |
| 69 | | "globe" | 69 | | "globe" |
| 70 | | "hammer" | 70 | | "hammer" |
| 71 | | "lock" | ||
| 71 | | "message" | 72 | | "message" |
| 72 | | "warning" | 73 | | "warning" |
| 73 | | "wrench" | 74 | | "wrench" |
| ... | @@ -971,6 +972,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { | ... | @@ -971,6 +972,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { |
| 971 | if (entry.requestKind === "command") return "command"; | 972 | if (entry.requestKind === "command") return "command"; |
| 972 | if (entry.requestKind === "file-read") return "eye"; | 973 | if (entry.requestKind === "file-read") return "eye"; |
| 973 | if (entry.requestKind === "file-change") return "edit"; | 974 | if (entry.requestKind === "file-change") return "edit"; |
| 975 | if (entry.requestKind === "permission") return "lock"; | ||
| 974 | if (entry.itemType === "command_execution" || entry.command) return "command"; | 976 | if (entry.itemType === "command_execution" || entry.command) return "command"; |
| 975 | if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit"; | 977 | if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit"; |
| 976 | if (entry.itemType === "web_search") return "globe"; | 978 | if (entry.itemType === "web_search") return "globe"; |
| ... | @@ -1439,7 +1441,8 @@ function extractWorkLogRequestKind( | ... | @@ -1439,7 +1441,8 @@ function extractWorkLogRequestKind( |
| 1439 | if ( | 1441 | if ( |
| 1440 | payload?.requestKind === "command" || | 1442 | payload?.requestKind === "command" || |
| 1441 | payload?.requestKind === "file-read" || | 1443 | payload?.requestKind === "file-read" || |
| 1442 | payload?.requestKind === "file-change" | 1444 | payload?.requestKind === "file-change" || |
| 1445 | payload?.requestKind === "permission" | ||
| 1443 | ) { | 1446 | ) { |
| 1444 | return payload.requestKind; | 1447 | return payload.requestKind; |
| 1445 | } | 1448 | } |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+7-2| ... | @@ -14,6 +14,7 @@ import { | ... | @@ -14,6 +14,7 @@ import { |
| 14 | type OrchestrationCheckpointSummary, | 14 | type OrchestrationCheckpointSummary, |
| 15 | type OrchestrationThreadActivity, | 15 | type OrchestrationThreadActivity, |
| 16 | type ProjectId, | 16 | type ProjectId, |
| 17 | type ProviderRequestKind, | ||
| 17 | type ProviderRuntimeEvent, | 18 | type ProviderRuntimeEvent, |
| 18 | type ResponseStreamingMode, | 19 | type ResponseStreamingMode, |
| 19 | RuntimeRequestId, | 20 | RuntimeRequestId, |
| ... | @@ -400,7 +401,7 @@ function sessionStatusAllowsActiveTurn( | ... | @@ -400,7 +401,7 @@ function sessionStatusAllowsActiveTurn( |
| 400 | 401 | ||
| 401 | function requestKindFromCanonicalRequestType( | 402 | function requestKindFromCanonicalRequestType( |
| 402 | requestType: string | undefined, | 403 | requestType: string | undefined, |
| 403 | ): "command" | "file-read" | "file-change" | "mcp-elicitation" | undefined { | 404 | ): ProviderRequestKind | undefined { |
| 404 | switch (requestType) { | 405 | switch (requestType) { |
| 405 | case "command_execution_approval": | 406 | case "command_execution_approval": |
| 406 | case "exec_command_approval": | 407 | case "exec_command_approval": |
| ... | @@ -412,6 +413,8 @@ function requestKindFromCanonicalRequestType( | ... | @@ -412,6 +413,8 @@ function requestKindFromCanonicalRequestType( |
| 412 | return "file-change"; | 413 | return "file-change"; |
| 413 | case "mcp_elicitation_approval": | 414 | case "mcp_elicitation_approval": |
| 414 | return "mcp-elicitation"; | 415 | return "mcp-elicitation"; |
| 416 | case "permission_approval": | ||
| 417 | return "permission"; | ||
| 415 | default: | 418 | default: |
| 416 | return undefined; | 419 | return undefined; |
| 417 | } | 420 | } |
| ... | @@ -494,7 +497,9 @@ export function runtimeEventToActivities( | ... | @@ -494,7 +497,9 @@ export function runtimeEventToActivities( |
| 494 | ? "File-change approval requested" | 497 | ? "File-change approval requested" |
| 495 | : requestKind === "mcp-elicitation" | 498 | : requestKind === "mcp-elicitation" |
| 496 | ? "App access approval requested" | 499 | ? "App access approval requested" |
| 497 | : "Approval requested", | 500 | : requestKind === "permission" |
| 501 | ? "App permission approval requested" | ||
| 502 | : "Approval requested", | ||
| 498 | payload: { | 503 | payload: { |
| 499 | requestId: toApprovalRequestId(event.requestId), | 504 | requestId: toApprovalRequestId(event.requestId), |
| 500 | ...(requestKind ? { requestKind } : {}), | 505 | ...(requestKind ? { requestKind } : {}), |
apps/server/src/provider/Layers/CodexAdapter.test.ts+42| ... | @@ -1719,6 +1719,48 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -1719,6 +1719,48 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 1719 | }), | 1719 | }), |
| 1720 | ); | 1720 | ); |
| 1721 | 1721 | ||
| 1722 | it.effect("maps app permission approval requests to permission_approval request types", () => | ||
| 1723 | Effect.gen(function* () { | ||
| 1724 | const { adapter, runtime } = yield* startLifecycleRuntime(); | ||
| 1725 | const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); | ||
| 1726 | |||
| 1727 | yield* runtime.emit({ | ||
| 1728 | id: asEventId("evt-app-permission-request"), | ||
| 1729 | kind: "request", | ||
| 1730 | provider: ProviderDriverKind.make("codex"), | ||
| 1731 | threadId: asThreadId("thread-1"), | ||
| 1732 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 1733 | method: "item/permissions/requestApproval", | ||
| 1734 | requestId: ApprovalRequestId.make("req-perm-1"), | ||
| 1735 | requestKind: "permission", | ||
| 1736 | turnId: asTurnId("turn-1"), | ||
| 1737 | itemId: asItemId("app_1"), | ||
| 1738 | payload: { | ||
| 1739 | cwd: "/tmp/project", | ||
| 1740 | itemId: "app_1", | ||
| 1741 | permissions: { network: { enabled: true } }, | ||
| 1742 | reason: "Fetch data from api.example.com", | ||
| 1743 | startedAtMs: 1_778_000_000_000, | ||
| 1744 | threadId: "thread-1", | ||
| 1745 | turnId: "turn-1", | ||
| 1746 | }, | ||
| 1747 | } satisfies ProviderEvent); | ||
| 1748 | |||
| 1749 | const firstEvent = yield* Fiber.join(firstEventFiber); | ||
| 1750 | |||
| 1751 | NodeAssert.equal(firstEvent._tag, "Some"); | ||
| 1752 | if (firstEvent._tag !== "Some") { | ||
| 1753 | return; | ||
| 1754 | } | ||
| 1755 | NodeAssert.equal(firstEvent.value.type, "request.opened"); | ||
| 1756 | if (firstEvent.value.type !== "request.opened") { | ||
| 1757 | return; | ||
| 1758 | } | ||
| 1759 | NodeAssert.equal(firstEvent.value.payload.requestType, "permission_approval"); | ||
| 1760 | NodeAssert.equal(firstEvent.value.payload.detail, "Fetch data from api.example.com"); | ||
| 1761 | }), | ||
| 1762 | ); | ||
| 1763 | |||
| 1722 | it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () => | 1764 | it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () => |
| 1723 | Effect.gen(function* () { | 1765 | Effect.gen(function* () { |
| 1724 | const { adapter, runtime } = yield* startLifecycleRuntime(); | 1766 | const { adapter, runtime } = yield* startLifecycleRuntime(); |
apps/server/src/provider/Layers/CodexAdapter.ts+18| ... | @@ -832,6 +832,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType { | ... | @@ -832,6 +832,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType { |
| 832 | return "file_change_approval"; | 832 | return "file_change_approval"; |
| 833 | case "mcpServer/elicitation/request": | 833 | case "mcpServer/elicitation/request": |
| 834 | return "mcp_elicitation_approval"; | 834 | return "mcp_elicitation_approval"; |
| 835 | case "item/permissions/requestApproval": | ||
| 836 | return "permission_approval"; | ||
| 835 | case "applyPatchApproval": | 837 | case "applyPatchApproval": |
| 836 | return "apply_patch_approval"; | 838 | return "apply_patch_approval"; |
| 837 | case "execCommandApproval": | 839 | case "execCommandApproval": |
| ... | @@ -857,6 +859,8 @@ function toRequestTypeFromKind(kind: ProviderRequestKind | undefined): Canonical | ... | @@ -857,6 +859,8 @@ function toRequestTypeFromKind(kind: ProviderRequestKind | undefined): Canonical |
| 857 | return "file_change_approval"; | 859 | return "file_change_approval"; |
| 858 | case "mcp-elicitation": | 860 | case "mcp-elicitation": |
| 859 | return "mcp_elicitation_approval"; | 861 | return "mcp_elicitation_approval"; |
| 862 | case "permission": | ||
| 863 | return "permission_approval"; | ||
| 860 | default: | 864 | default: |
| 861 | return "unknown"; | 865 | return "unknown"; |
| 862 | } | 866 | } |
| ... | @@ -1366,6 +1370,20 @@ function mapToRuntimeEvents( | ... | @@ -1366,6 +1370,20 @@ function mapToRuntimeEvents( |
| 1366 | } | 1370 | } |
| 1367 | case "mcpServer/elicitation/request": | 1371 | case "mcpServer/elicitation/request": |
| 1368 | return elicitation?.message; | 1372 | return elicitation?.message; |
| 1373 | case "item/permissions/requestApproval": { | ||
| 1374 | const payload = readPayload( | ||
| 1375 | EffectCodexSchema.ServerRequest__PermissionsRequestApprovalParams, | ||
| 1376 | event.payload, | ||
| 1377 | ); | ||
| 1378 | const requestedPaths = [ | ||
| 1379 | ...(payload?.permissions.fileSystem?.read ?? []), | ||
| 1380 | ...(payload?.permissions.fileSystem?.write ?? []), | ||
| 1381 | ]; | ||
| 1382 | return ( | ||
| 1383 | nonEmptyDetail(payload?.reason) ?? | ||
| 1384 | (requestedPaths.length > 0 ? `Access: ${requestedPaths.join(", ")}` : undefined) | ||
| 1385 | ); | ||
| 1386 | } | ||
| 1369 | case "applyPatchApproval": { | 1387 | case "applyPatchApproval": { |
| 1370 | const payload = readPayload( | 1388 | const payload = readPayload( |
| 1371 | EffectCodexSchema.ServerRequest__ApplyPatchApprovalParams, | 1389 | EffectCodexSchema.ServerRequest__ApplyPatchApprovalParams, |
apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts+102| ... | @@ -605,6 +605,108 @@ describe("CodexSessionRuntime collab integration", () => { | ... | @@ -605,6 +605,108 @@ describe("CodexSessionRuntime collab integration", () => { |
| 605 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 605 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 606 | ); | 606 | ); |
| 607 | 607 | ||
| 608 | // it.live: the runtime talks to a real child process; under it.effect's | ||
| 609 | // TestClock the internal timers freeze and the join never completes. | ||
| 610 | it.live("Stop answers a parked app-permission approval with a withheld grant", () => | ||
| 611 | Effect.gen(function* () { | ||
| 612 | // Interrupting a turn whose app-permission prompt is still parked must | ||
| 613 | // settle that prompt: the handler resumes with "cancel", the peer gets | ||
| 614 | // an empty grant (permission withheld), and nothing hangs until close. | ||
| 615 | const script = { | ||
| 616 | rootThreadId: ROOT, | ||
| 617 | holdTurnOpen: true, | ||
| 618 | notifications: [], | ||
| 619 | serverRequests: [ | ||
| 620 | { | ||
| 621 | method: "item/permissions/requestApproval", | ||
| 622 | label: "perm-1", | ||
| 623 | params: { | ||
| 624 | cwd: "/tmp/project", | ||
| 625 | itemId: "app_1", | ||
| 626 | permissions: { network: { enabled: true } }, | ||
| 627 | reason: "Fetch data from api.example.com", | ||
| 628 | startedAtMs: 1_778_000_000_000, | ||
| 629 | threadId: "${threadId}", | ||
| 630 | turnId: "${turnId}", | ||
| 631 | }, | ||
| 632 | }, | ||
| 633 | ], | ||
| 634 | }; | ||
| 635 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 636 | NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); | ||
| 637 | const responsesPath = `${scriptPath}.approvalResponses`; | ||
| 638 | NodeFS.rmSync(responsesPath, { force: true }); | ||
| 639 | yield* Effect.addFinalizer(() => | ||
| 640 | Effect.sync(() => { | ||
| 641 | NodeFS.rmSync(scriptPath, { force: true }); | ||
| 642 | NodeFS.rmSync(responsesPath, { force: true }); | ||
| 643 | }), | ||
| 644 | ); | ||
| 645 | |||
| 646 | const runtime = yield* makeCodexSessionRuntime({ | ||
| 647 | threadId: ThreadId.make("thread-codex-permission-stop"), | ||
| 648 | binaryPath: peerPath, | ||
| 649 | cwd: "/tmp", | ||
| 650 | runtimeMode: "full-access", | ||
| 651 | environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, | ||
| 652 | }); | ||
| 653 | |||
| 654 | // One consumer for the whole stream: `events` is a plain queue stream, | ||
| 655 | // so two forks would compete for events and each could starve the | ||
| 656 | // other's filter. Signal the two milestones through Deferreds instead. | ||
| 657 | const requestedReady = yield* Deferred.make<ProviderEvent>(); | ||
| 658 | const settledReady = yield* Deferred.make<ProviderEvent>(); | ||
| 659 | yield* runtime.events.pipe( | ||
| 660 | Stream.runForEach((event) => { | ||
| 661 | if (event.method === "item/permissions/requestApproval") { | ||
| 662 | return Deferred.succeed(requestedReady, event); | ||
| 663 | } | ||
| 664 | if (event.method === "serverRequest/resolved" && event.requestKind === "permission") { | ||
| 665 | return Deferred.succeed(settledReady, event); | ||
| 666 | } | ||
| 667 | return Effect.void; | ||
| 668 | }), | ||
| 669 | Effect.forkScoped, | ||
| 670 | ); | ||
| 671 | |||
| 672 | yield* runtime.start(); | ||
| 673 | yield* runtime.sendTurn({ input: "use the connected app" }); | ||
| 674 | const requested = yield* Deferred.await(requestedReady).pipe( | ||
| 675 | Effect.timeoutOption("15 seconds"), | ||
| 676 | ); | ||
| 677 | assert.isTrue(requested._tag === "Some", "permission approval request never arrived"); | ||
| 678 | |||
| 679 | yield* runtime.interruptTurn(); | ||
| 680 | |||
| 681 | // The peer emits serverRequest/resolved only AFTER recording the | ||
| 682 | // runtime's answer, so awaiting this receipt makes reading the sidecar | ||
| 683 | // race-free. The runtime correlates that receipt back to the canonical | ||
| 684 | // request (requestKind + requestId) — the same event chain the adapter | ||
| 685 | // folds into approval.resolved, so the card actually closes. | ||
| 686 | const settled = yield* Deferred.await(settledReady).pipe(Effect.timeoutOption("15 seconds")); | ||
| 687 | assert.isTrue(settled._tag === "Some", "interrupt did not settle the parked approval"); | ||
| 688 | const settledEvent = settled._tag === "Some" ? settled.value : undefined; | ||
| 689 | assert.isDefined(settledEvent); | ||
| 690 | assert.isDefined( | ||
| 691 | settledEvent?.requestId, | ||
| 692 | "receipt must correlate back to the canonical approval request", | ||
| 693 | ); | ||
| 694 | |||
| 695 | const recorded = NodeFS.readFileSync(responsesPath, "utf8") | ||
| 696 | .trim() | ||
| 697 | .split("\n") | ||
| 698 | .map((line) => JSON.parse(line) as { id: number; label: string; result: unknown }); | ||
| 699 | assert.equal(recorded.length, 1); | ||
| 700 | const answer = recorded[0]; | ||
| 701 | assert.isDefined(answer); | ||
| 702 | assert.equal(answer.label, "perm-1"); | ||
| 703 | // Cancelled approvals withhold the grant: an empty permission profile. | ||
| 704 | assert.deepEqual(answer.result, { permissions: {} }); | ||
| 705 | |||
| 706 | yield* runtime.close; | ||
| 707 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 708 | ); | ||
| 709 | |||
| 608 | it.live("Stop targets the active turn when Codex has accepted a queued follow-up", () => | 710 | it.live("Stop targets the active turn when Codex has accepted a queued follow-up", () => |
| 609 | Effect.gen(function* () { | 711 | Effect.gen(function* () { |
| 610 | const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; | 712 | const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+73| ... | @@ -2232,6 +2232,69 @@ export const makeCodexSessionRuntime = ( | ... | @@ -2232,6 +2232,69 @@ export const makeCodexSessionRuntime = ( |
| 2232 | }), | 2232 | }), |
| 2233 | ); | 2233 | ); |
| 2234 | 2234 | ||
| 2235 | yield* client.handleServerRequest("item/permissions/requestApproval", (payload) => | ||
| 2236 | Effect.gen(function* () { | ||
| 2237 | const requestId = ApprovalRequestId.make( | ||
| 2238 | yield* randomUUIDv4("app-permission-approval-request"), | ||
| 2239 | ); | ||
| 2240 | const turnId = TurnId.make(payload.turnId); | ||
| 2241 | const itemId = ProviderItemId.make(payload.itemId); | ||
| 2242 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); | ||
| 2243 | |||
| 2244 | yield* Ref.update(pendingApprovalsRef, (current) => { | ||
| 2245 | const next = new Map(current); | ||
| 2246 | next.set(requestId, { | ||
| 2247 | requestId, | ||
| 2248 | jsonRpcId: payload.itemId, | ||
| 2249 | requestKind: "permission", | ||
| 2250 | turnId, | ||
| 2251 | itemId, | ||
| 2252 | decision, | ||
| 2253 | }); | ||
| 2254 | return next; | ||
| 2255 | }); | ||
| 2256 | yield* Ref.update(approvalCorrelationsRef, (current) => { | ||
| 2257 | const next = new Map(current); | ||
| 2258 | next.set(payload.itemId, { | ||
| 2259 | requestId, | ||
| 2260 | requestKind: "permission", | ||
| 2261 | turnId, | ||
| 2262 | itemId, | ||
| 2263 | }); | ||
| 2264 | return next; | ||
| 2265 | }); | ||
| 2266 | |||
| 2267 | yield* emitEvent({ | ||
| 2268 | kind: "request", | ||
| 2269 | threadId: options.threadId, | ||
| 2270 | method: "item/permissions/requestApproval", | ||
| 2271 | requestId, | ||
| 2272 | requestKind: "permission", | ||
| 2273 | ...(turnId ? { turnId } : {}), | ||
| 2274 | ...(itemId ? { itemId } : {}), | ||
| 2275 | payload, | ||
| 2276 | }); | ||
| 2277 | |||
| 2278 | const resolved = yield* Deferred.await(decision).pipe( | ||
| 2279 | Effect.ensuring( | ||
| 2280 | Ref.update(pendingApprovalsRef, (current) => { | ||
| 2281 | const next = new Map(current); | ||
| 2282 | next.delete(requestId); | ||
| 2283 | return next; | ||
| 2284 | }), | ||
| 2285 | ), | ||
| 2286 | ); | ||
| 2287 | // Approving grants the requested profile; denying answers with an | ||
| 2288 | // empty grant so the app-server treats the permission as withheld. | ||
| 2289 | const grantedPermissions = | ||
| 2290 | resolved === "accept" || resolved === "acceptForSession" ? payload.permissions : {}; | ||
| 2291 | return { | ||
| 2292 | permissions: grantedPermissions, | ||
| 2293 | ...(resolved === "acceptForSession" ? { scope: "session" as const } : {}), | ||
| 2294 | } satisfies EffectCodexSchema.PermissionsRequestApprovalResponse; | ||
| 2295 | }), | ||
| 2296 | ); | ||
| 2297 | |||
| 2235 | yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => | 2298 | yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => |
| 2236 | Effect.gen(function* () { | 2299 | Effect.gen(function* () { |
| 2237 | const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); | 2300 | const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); |
| ... | @@ -2499,6 +2562,16 @@ export const makeCodexSessionRuntime = ( | ... | @@ -2499,6 +2562,16 @@ export const makeCodexSessionRuntime = ( |
| 2499 | Effect.gen(function* () { | 2562 | Effect.gen(function* () { |
| 2500 | const providerThreadId = yield* readProviderThreadId; | 2563 | const providerThreadId = yield* readProviderThreadId; |
| 2501 | const session = yield* Ref.get(sessionRef); | 2564 | const session = yield* Ref.get(sessionRef); |
| 2565 | // Settle parked approvals FIRST. The transport answers server | ||
| 2566 | // requests inline on its stdin read loop, so a pending | ||
| 2567 | // command/file/app-permission prompt blocks every incoming message, | ||
| 2568 | // including the turn/interrupt response itself - cancelling after | ||
| 2569 | // the RPC would deadlock Stop exactly when a card is open. Settling | ||
| 2570 | // releases the handler, which answers the peer and unblocks the | ||
| 2571 | // loop before the interrupts below are sent. | ||
| 2572 | yield* settlePendingApprovals("cancel"); | ||
| 2573 | // Pending user-input prompts block the same way; settle them too. | ||
| 2574 | yield* settlePendingUserInputs({}); | ||
| 2502 | // Stop-everything: children are full threads with their own turns; | 2575 | // Stop-everything: children are full threads with their own turns; |
| 2503 | // interrupting only the parent leaves the fleet running. Interrupt | 2576 | // interrupting only the parent leaves the fleet running. Interrupt |
| 2504 | // each live child turn first, best-effort per child, BOUNDED: the | 2577 | // each live child turn first, best-effort per child, BOUNDED: the |
apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs+45-3| ... | @@ -18,7 +18,9 @@ const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT | ... | @@ -18,7 +18,9 @@ const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT |
| 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 | let activeTurn; |
| 21 | 21 | // Server->client requests the runtime must answer (approval prompts), keyed | |
| 22 | // by the numeric JSON-RPC id this peer allocated for them. | ||
| 23 | const openServerRequests = new Map(); | ||
| 22 | const rl = NodeReadline.createInterface({ input: process.stdin }); | 24 | const rl = NodeReadline.createInterface({ input: process.stdin }); |
| 23 | rl.on("line", (line) => { | 25 | rl.on("line", (line) => { |
| 24 | let message; | 26 | let message; |
| ... | @@ -28,6 +30,30 @@ rl.on("line", (line) => { | ... | @@ -28,6 +30,30 @@ rl.on("line", (line) => { |
| 28 | return; | 30 | return; |
| 29 | } | 31 | } |
| 30 | const { id, method } = message; | 32 | const { id, method } = message; |
| 33 | if (openServerRequests.has(id)) { | ||
| 34 | // The runtime answered an approval request. Record the response so tests | ||
| 35 | // can assert settlement behavior, then emit serverRequest/resolved as a | ||
| 36 | // deterministic receipt (tests wait on the runtime's event stream rather | ||
| 37 | // than polling the sidecar file). Real codex identifies the resolved | ||
| 38 | // request by its item id — the key the runtime correlates on — so the | ||
| 39 | // receipt exercises the same path that closes the approval card in the | ||
| 40 | // UI. | ||
| 41 | const request = openServerRequests.get(id); | ||
| 42 | openServerRequests.delete(id); | ||
| 43 | NodeFS.appendFileSync( | ||
| 44 | `${process.env.T3_CODEX_COLLAB_SCRIPT}.approvalResponses`, | ||
| 45 | `${JSON.stringify({ id, label: request.label, result: message.result ?? null })}\n`, | ||
| 46 | ); | ||
| 47 | write({ | ||
| 48 | jsonrpc: "2.0", | ||
| 49 | method: "serverRequest/resolved", | ||
| 50 | params: { | ||
| 51 | threadId: script.rootThreadId, | ||
| 52 | requestId: request.itemId ?? request.label, | ||
| 53 | }, | ||
| 54 | }); | ||
| 55 | return; | ||
| 56 | } | ||
| 31 | if (method === undefined && script.serverRequests?.some((request) => request.id === id)) { | 57 | if (method === undefined && script.serverRequests?.some((request) => request.id === id)) { |
| 32 | NodeFS.appendFileSync( | 58 | NodeFS.appendFileSync( |
| 33 | `${process.env.T3_CODEX_COLLAB_SCRIPT}.responses`, | 59 | `${process.env.T3_CODEX_COLLAB_SCRIPT}.responses`, |
| ... | @@ -136,8 +162,24 @@ rl.on("line", (line) => { | ... | @@ -136,8 +162,24 @@ rl.on("line", (line) => { |
| 136 | for (const notification of script.notifications) { | 162 | for (const notification of script.notifications) { |
| 137 | write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); | 163 | write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); |
| 138 | } | 164 | } |
| 139 | for (const request of script.serverRequests ?? []) { | 165 | for (const [index, request] of (script.serverRequests ?? []).entries()) { |
| 140 | write({ jsonrpc: "2.0", id: request.id, method: request.method, params: request.params }); | 166 | if (request.id !== undefined) { |
| 167 | // Legacy form: caller-supplied JSON-RPC ids recorded in .responses. | ||
| 168 | write({ jsonrpc: "2.0", id: request.id, method: request.method, params: request.params }); | ||
| 169 | continue; | ||
| 170 | } | ||
| 171 | // Scripted server->client requests (approval prompts). String values in | ||
| 172 | // params may reference "${threadId}" / "${turnId}" placeholders that are | ||
| 173 | // substituted with the ids this peer actually allocated. | ||
| 174 | const requestId = 9000 + index; | ||
| 175 | const label = request.label ?? `approval-${requestId}`; | ||
| 176 | openServerRequests.set(requestId, { label, itemId: request.params?.itemId }); | ||
| 177 | const params = JSON.parse( | ||
| 178 | JSON.stringify(request.params) | ||
| 179 | .replaceAll("${threadId}", String(rootThreadId)) | ||
| 180 | .replaceAll("${turnId}", String(turn.id)), | ||
| 181 | ); | ||
| 182 | write({ jsonrpc: "2.0", id: requestId, method: request.method, params }); | ||
| 141 | } | 183 | } |
| 142 | if (script.holdTurnOpen !== true) { | 184 | if (script.holdTurnOpen !== true) { |
| 143 | write({ | 185 | write({ |
apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx+6-2| ... | @@ -21,7 +21,9 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova | ... | @@ -21,7 +21,9 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova |
| 21 | ? "Command approval" | 21 | ? "Command approval" |
| 22 | : approval.requestKind === "file-read" | 22 | : approval.requestKind === "file-read" |
| 23 | ? "File read approval" | 23 | ? "File read approval" |
| 24 | : "File change approval"; | 24 | : approval.requestKind === "permission" |
| 25 | ? "App permission approval" | ||
| 26 | : "File change approval"; | ||
| 25 | const detailAriaLabel = | 27 | const detailAriaLabel = |
| 26 | approval.requestKind === "mcp-elicitation" | 28 | approval.requestKind === "mcp-elicitation" |
| 27 | ? "App access request" | 29 | ? "App access request" |
| ... | @@ -29,7 +31,9 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova | ... | @@ -29,7 +31,9 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova |
| 29 | ? "Command" | 31 | ? "Command" |
| 30 | : approval.requestKind === "file-read" | 32 | : approval.requestKind === "file-read" |
| 31 | ? "File to read" | 33 | ? "File to read" |
| 32 | : "File change"; | 34 | : approval.requestKind === "permission" |
| 35 | ? "Permission request" | ||
| 36 | : "File change"; | ||
| 33 | 37 | ||
| 34 | return ( | 38 | return ( |
| 35 | <span | 39 | <span |
apps/web/src/session-logic.ts+2-1| ... | @@ -1316,7 +1316,8 @@ function extractWorkLogRequestKind( | ... | @@ -1316,7 +1316,8 @@ function extractWorkLogRequestKind( |
| 1316 | if ( | 1316 | if ( |
| 1317 | payload?.requestKind === "command" || | 1317 | payload?.requestKind === "command" || |
| 1318 | payload?.requestKind === "file-read" || | 1318 | payload?.requestKind === "file-read" || |
| 1319 | payload?.requestKind === "file-change" | 1319 | payload?.requestKind === "file-change" || |
| 1320 | payload?.requestKind === "permission" | ||
| 1320 | ) { | 1321 | ) { |
| 1321 | return payload.requestKind; | 1322 | return payload.requestKind; |
| 1322 | } | 1323 | } |
packages/client-runtime/src/pendingRequests.ts+2| ... | @@ -59,6 +59,8 @@ export function requestKindFromRequestType(requestType: unknown): ProviderReques | ... | @@ -59,6 +59,8 @@ export function requestKindFromRequestType(requestType: unknown): ProviderReques |
| 59 | return "file-change"; | 59 | return "file-change"; |
| 60 | case "mcp_elicitation_approval": | 60 | case "mcp_elicitation_approval": |
| 61 | return "mcp-elicitation"; | 61 | return "mcp-elicitation"; |
| 62 | case "permission_approval": | ||
| 63 | return "permission"; | ||
| 62 | default: | 64 | default: |
| 63 | return null; | 65 | return null; |
| 64 | } | 66 | } |
packages/contracts/src/orchestration.ts+1| ... | @@ -141,6 +141,7 @@ export const ProviderRequestKind = Schema.Literals([ | ... | @@ -141,6 +141,7 @@ export const ProviderRequestKind = Schema.Literals([ |
| 141 | "file-read", | 141 | "file-read", |
| 142 | "file-change", | 142 | "file-change", |
| 143 | "mcp-elicitation", | 143 | "mcp-elicitation", |
| 144 | "permission", | ||
| 144 | ]); | 145 | ]); |
| 145 | export type ProviderRequestKind = typeof ProviderRequestKind.Type; | 146 | export type ProviderRequestKind = typeof ProviderRequestKind.Type; |
| 146 | export const ProviderApprovalDecision = Schema.Literals([ | 147 | export const ProviderApprovalDecision = Schema.Literals([ |
packages/contracts/src/providerRuntime.ts+1| ... | @@ -141,6 +141,7 @@ export const CanonicalRequestType = Schema.Literals([ | ... | @@ -141,6 +141,7 @@ export const CanonicalRequestType = Schema.Literals([ |
| 141 | "apply_patch_approval", | 141 | "apply_patch_approval", |
| 142 | "exec_command_approval", | 142 | "exec_command_approval", |
| 143 | "mcp_elicitation_approval", | 143 | "mcp_elicitation_approval", |
| 144 | "permission_approval", | ||
| 144 | "tool_user_input", | 145 | "tool_user_input", |
| 145 | "dynamic_tool_call", | 146 | "dynamic_tool_call", |
| 146 | "auth_tokens_refresh", | 147 | "auth_tokens_refresh", |
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 | "command-approval-request", | 118 | "command-approval-request", |
| 119 | "file-change-approval-request", | 119 | "file-change-approval-request", |
| 120 | "mcp-elicitation-request", | 120 | "mcp-elicitation-request", |
| 121 | "app-permission-approval-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; |