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";
6464import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle";
6565import IconKeyboard from "@tabler/icons-react-native/IconKeyboard";
6666import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide";
67import IconLock from "@tabler/icons-react-native/IconLock";
6768import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns";
6869import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar";
6970import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight";
......@@ -240,6 +241,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME = {
240241 keyboard_arrow_down: IconChevronDown,
241242 keyboard_arrow_up: IconChevronUp,
242243 keyboard_hide: IconKeyboardHide,
244 lock: IconLock,
243245 more_vert: IconDotsVertical,
244246 merge: IconGitMerge,
245247 public: IconWorld,
apps/mobile/src/features/threads/thread-work-log.tsx+2
......@@ -363,6 +363,8 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName {
363363 return { ios: "globe", android: "public" };
364364 case "hammer":
365365 return { ios: "hammer", android: "construction" };
366 case "lock":
367 return { ios: "lock", android: "lock" };
366368 case "message":
367369 return { ios: "bubble.left", android: "chat_bubble" };
368370 case "warning":
apps/mobile/src/lib/threadActivity.ts+4-1
......@@ -68,6 +68,7 @@ export interface ThreadFeedActivity {
6868 | "eye"
6969 | "globe"
7070 | "hammer"
71 | "lock"
7172 | "message"
7273 | "warning"
7374 | "wrench"
......@@ -971,6 +972,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
971972 if (entry.requestKind === "command") return "command";
972973 if (entry.requestKind === "file-read") return "eye";
973974 if (entry.requestKind === "file-change") return "edit";
975 if (entry.requestKind === "permission") return "lock";
974976 if (entry.itemType === "command_execution" || entry.command) return "command";
975977 if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit";
976978 if (entry.itemType === "web_search") return "globe";
......@@ -1439,7 +1441,8 @@ function extractWorkLogRequestKind(
14391441 if (
14401442 payload?.requestKind === "command" ||
14411443 payload?.requestKind === "file-read" ||
1442 payload?.requestKind === "file-change"
1444 payload?.requestKind === "file-change" ||
1445 payload?.requestKind === "permission"
14431446 ) {
14441447 return payload.requestKind;
14451448 }
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+7-2
......@@ -14,6 +14,7 @@ import {
1414 type OrchestrationCheckpointSummary,
1515 type OrchestrationThreadActivity,
1616 type ProjectId,
17 type ProviderRequestKind,
1718 type ProviderRuntimeEvent,
1819 type ResponseStreamingMode,
1920 RuntimeRequestId,
......@@ -400,7 +401,7 @@ function sessionStatusAllowsActiveTurn(
400401
401402function requestKindFromCanonicalRequestType(
402403 requestType: string | undefined,
403): "command" | "file-read" | "file-change" | "mcp-elicitation" | undefined {
404): ProviderRequestKind | undefined {
404405 switch (requestType) {
405406 case "command_execution_approval":
406407 case "exec_command_approval":
......@@ -412,6 +413,8 @@ function requestKindFromCanonicalRequestType(
412413 return "file-change";
413414 case "mcp_elicitation_approval":
414415 return "mcp-elicitation";
416 case "permission_approval":
417 return "permission";
415418 default:
416419 return undefined;
417420 }
......@@ -494,7 +497,9 @@ export function runtimeEventToActivities(
494497 ? "File-change approval requested"
495498 : requestKind === "mcp-elicitation"
496499 ? "App access approval requested"
497 : "Approval requested",
500 : requestKind === "permission"
501 ? "App permission approval requested"
502 : "Approval requested",
498503 payload: {
499504 requestId: toApprovalRequestId(event.requestId),
500505 ...(requestKind ? { requestKind } : {}),
apps/server/src/provider/Layers/CodexAdapter.test.ts+42
......@@ -1719,6 +1719,48 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
17191719 }),
17201720 );
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
17221764 it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () =>
17231765 Effect.gen(function* () {
17241766 const { adapter, runtime } = yield* startLifecycleRuntime();
apps/server/src/provider/Layers/CodexAdapter.ts+18
......@@ -832,6 +832,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType {
832832 return "file_change_approval";
833833 case "mcpServer/elicitation/request":
834834 return "mcp_elicitation_approval";
835 case "item/permissions/requestApproval":
836 return "permission_approval";
835837 case "applyPatchApproval":
836838 return "apply_patch_approval";
837839 case "execCommandApproval":
......@@ -857,6 +859,8 @@ function toRequestTypeFromKind(kind: ProviderRequestKind | undefined): Canonical
857859 return "file_change_approval";
858860 case "mcp-elicitation":
859861 return "mcp_elicitation_approval";
862 case "permission":
863 return "permission_approval";
860864 default:
861865 return "unknown";
862866 }
......@@ -1366,6 +1370,20 @@ function mapToRuntimeEvents(
13661370 }
13671371 case "mcpServer/elicitation/request":
13681372 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 }
13691387 case "applyPatchApproval": {
13701388 const payload = readPayload(
13711389 EffectCodexSchema.ServerRequest__ApplyPatchApprovalParams,
apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts+102
......@@ -605,6 +605,108 @@ describe("CodexSessionRuntime collab integration", () => {
605605 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
606606 );
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
608710 it.live("Stop targets the active turn when Codex has accepted a queued follow-up", () =>
609711 Effect.gen(function* () {
610712 const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a";
apps/server/src/provider/Layers/CodexSessionRuntime.ts+73
......@@ -2232,6 +2232,69 @@ export const makeCodexSessionRuntime = (
22322232 }),
22332233 );
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
22352298 yield* client.handleServerRequest("item/tool/requestUserInput", (payload) =>
22362299 Effect.gen(function* () {
22372300 const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request"));
......@@ -2499,6 +2562,16 @@ export const makeCodexSessionRuntime = (
24992562 Effect.gen(function* () {
25002563 const providerThreadId = yield* readProviderThreadId;
25012564 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({});
25022575 // Stop-everything: children are full threads with their own turns;
25032576 // interrupting only the parent leaves the fleet running. Interrupt
25042577 // 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
1818const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
1919let turnStartCount = 0;
2020let 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.
23const openServerRequests = new Map();
2224const rl = NodeReadline.createInterface({ input: process.stdin });
2325rl.on("line", (line) => {
2426 let message;
......@@ -28,6 +30,30 @@ rl.on("line", (line) => {
2830 return;
2931 }
3032 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 }
3157 if (method === undefined && script.serverRequests?.some((request) => request.id === id)) {
3258 NodeFS.appendFileSync(
3359 `${process.env.T3_CODEX_COLLAB_SCRIPT}.responses`,
......@@ -136,8 +162,24 @@ rl.on("line", (line) => {
136162 for (const notification of script.notifications) {
137163 write({ jsonrpc: "2.0", method: notification.method, params: notification.params });
138164 }
139 for (const request of script.serverRequests ?? []) {
140 write({ jsonrpc: "2.0", id: request.id, method: request.method, params: request.params });
165 for (const [index, request] of (script.serverRequests ?? []).entries()) {
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 });
141183 }
142184 if (script.holdTurnOpen !== true) {
143185 write({
apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx+6-2
......@@ -21,7 +21,9 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
2121 ? "Command approval"
2222 : approval.requestKind === "file-read"
2323 ? "File read approval"
24 : "File change approval";
24 : approval.requestKind === "permission"
25 ? "App permission approval"
26 : "File change approval";
2527 const detailAriaLabel =
2628 approval.requestKind === "mcp-elicitation"
2729 ? "App access request"
......@@ -29,7 +31,9 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
2931 ? "Command"
3032 : approval.requestKind === "file-read"
3133 ? "File to read"
32 : "File change";
34 : approval.requestKind === "permission"
35 ? "Permission request"
36 : "File change";
3337
3438 return (
3539 <span
apps/web/src/session-logic.ts+2-1
......@@ -1316,7 +1316,8 @@ function extractWorkLogRequestKind(
13161316 if (
13171317 payload?.requestKind === "command" ||
13181318 payload?.requestKind === "file-read" ||
1319 payload?.requestKind === "file-change"
1319 payload?.requestKind === "file-change" ||
1320 payload?.requestKind === "permission"
13201321 ) {
13211322 return payload.requestKind;
13221323 }
packages/client-runtime/src/pendingRequests.ts+2
......@@ -59,6 +59,8 @@ export function requestKindFromRequestType(requestType: unknown): ProviderReques
5959 return "file-change";
6060 case "mcp_elicitation_approval":
6161 return "mcp-elicitation";
62 case "permission_approval":
63 return "permission";
6264 default:
6365 return null;
6466 }
packages/contracts/src/orchestration.ts+1
......@@ -141,6 +141,7 @@ export const ProviderRequestKind = Schema.Literals([
141141 "file-read",
142142 "file-change",
143143 "mcp-elicitation",
144 "permission",
144145]);
145146export type ProviderRequestKind = typeof ProviderRequestKind.Type;
146147export const ProviderApprovalDecision = Schema.Literals([
packages/contracts/src/providerRuntime.ts+1
......@@ -141,6 +141,7 @@ export const CanonicalRequestType = Schema.Literals([
141141 "apply_patch_approval",
142142 "exec_command_approval",
143143 "mcp_elicitation_approval",
144 "permission_approval",
144145 "tool_user_input",
145146 "dynamic_tool_call",
146147 "auth_tokens_refresh",
packages/effect-codex-app-server/src/errors.ts+1
......@@ -118,6 +118,7 @@ export const CodexAppServerIdentifierPurpose = Schema.Literals([
118118 "command-approval-request",
119119 "file-change-approval-request",
120120 "mcp-elicitation-request",
121 "app-permission-approval-request",
121122 "user-input-request",
122123]);
123124export type CodexAppServerIdentifierPurpose = typeof CodexAppServerIdentifierPurpose.Type;