authorgravatar for 134711311+Exotic209093@users.noreply.github.comJames C <134711311+Exotic209093@users.noreply.github.com> 2026-09-19 05:36:30+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 21:36:30-07:00
logefb96939fbb135b9f4de3bff19447200b178b23a
tree673649930de9e967cf3d857abe21baf2cd56b20e
parent82059df1523d070b00ab6f45159115f626a4977e
signature Signed by PGP key B5690EEEBB952194

fix(codex): surface app permission requests as approvable (#7861)

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";
64import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle";64import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle";
65import IconKeyboard from "@tabler/icons-react-native/IconKeyboard";65import IconKeyboard from "@tabler/icons-react-native/IconKeyboard";
66import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide";66import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide";
67import IconLock from "@tabler/icons-react-native/IconLock";
67import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns";68import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns";
68import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar";69import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar";
69import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight";70import 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(
400401
401function requestKindFromCanonicalRequestType(402function 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 );
17211721
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 );
607607
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 );
22342234
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. Interrupt2576 // interrupting only the parent leaves the fleet running. Interrupt
2504 // each live child turn first, best-effort per child, BOUNDED: the2577 // 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
18const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);18const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
19let turnStartCount = 0;19let turnStartCount = 0;
20let activeTurn;20let activeTurn;
2121// Server->client requests the runtime must answer (approval prompts), keyed
22// by the numeric JSON-RPC id this peer allocated for them.
23const openServerRequests = new Map();
22const rl = NodeReadline.createInterface({ input: process.stdin });24const rl = NodeReadline.createInterface({ input: process.stdin });
23rl.on("line", (line) => {25rl.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";
3337
34 return (38 return (
35 <span39 <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]);
145export type ProviderRequestKind = typeof ProviderRequestKind.Type;146export type ProviderRequestKind = typeof ProviderRequestKind.Type;
146export const ProviderApprovalDecision = Schema.Literals([147export 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]);
123export type CodexAppServerIdentifierPurpose = typeof CodexAppServerIdentifierPurpose.Type;124export type CodexAppServerIdentifierPurpose = typeof CodexAppServerIdentifierPurpose.Type;