From 0fe07483fac119210dedbfaf3e7098c7b2693e56 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 02:19:20 +0100 Subject: [PATCH] fix(codex): surface app permission requests as approvable Codex Apps ask for extra permissions via the JSON-RPC method item/permissions/requestApproval. The Codex adapter had no handler or request-type mapping for it, so the request fell through to methodNotFound and the tool call stalled with no approval UI outside Auto mode. Add a permission_approval canonical request type and a matching permission request kind, handle the method in CodexSessionRuntime (approve grants the requested profile, deny answers an empty grant), map it in CodexAdapter, and thread the kind through ingestion plus the web and mobile approval folds so the card renders everywhere. ox-alpha via opencode --- .../src/features/threads/thread-work-log.tsx | 2 + apps/mobile/src/lib/threadActivity.ts | 12 +++- .../Layers/ProviderRuntimeIngestion.ts | 9 ++- .../src/provider/Layers/CodexAdapter.test.ts | 42 +++++++++++++ .../src/provider/Layers/CodexAdapter.ts | 11 ++++ .../provider/Layers/CodexSessionRuntime.ts | 63 +++++++++++++++++++ .../chat/ComposerPendingApprovalPanel.tsx | 8 ++- apps/web/src/session-logic.ts | 10 ++- packages/contracts/src/orchestration.ts | 7 ++- packages/contracts/src/providerRuntime.ts | 1 + .../effect-codex-app-server/src/errors.ts | 1 + 11 files changed, 155 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index a5adacb8d19b..22e3cb27af05 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -61,6 +61,8 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { return { ios: "globe", android: "public" }; case "hammer": return { ios: "hammer", android: "construction" }; + case "lock": + return { ios: "lock", android: "lock" }; case "message": return { ios: "bubble.left", android: "chat_bubble" }; case "warning": diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fbcb2e1c7e2a..c6bc6dd01f32 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -14,7 +14,7 @@ import * as Order from "effect/Order"; export interface PendingApproval { readonly requestId: ApprovalRequestId; - readonly requestKind: "command" | "file-read" | "file-change"; + readonly requestKind: "command" | "file-read" | "file-change" | "permission"; readonly createdAt: string; readonly detail?: string; } @@ -48,6 +48,7 @@ export interface ThreadFeedActivity { | "eye" | "globe" | "hammer" + | "lock" | "message" | "warning" | "wrench" @@ -147,6 +148,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ case "file_change_approval": case "apply_patch_approval": return "file-change"; + case "permission_approval": + return "permission"; default: return null; } @@ -632,6 +635,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { if (entry.requestKind === "command") return "command"; if (entry.requestKind === "file-read") return "eye"; if (entry.requestKind === "file-change") return "edit"; + if (entry.requestKind === "permission") return "lock"; if (entry.itemType === "command_execution" || entry.command) return "command"; if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit"; if (entry.itemType === "web_search") return "globe"; @@ -967,7 +971,8 @@ function extractWorkLogRequestKind( if ( payload?.requestKind === "command" || payload?.requestKind === "file-read" || - payload?.requestKind === "file-change" + payload?.requestKind === "file-change" || + payload?.requestKind === "permission" ) { return payload.requestKind; } @@ -1367,7 +1372,8 @@ export function derivePendingApprovals( const requestKind = payload?.requestKind === "command" || payload?.requestKind === "file-read" || - payload?.requestKind === "file-change" + payload?.requestKind === "file-change" || + payload?.requestKind === "permission" ? payload.requestKind : requestKindFromRequestType(payload?.requestType); const detail = typeof payload?.detail === "string" ? payload.detail : undefined; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 953ba1ec9b0d..f43db8eaf459 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -17,6 +17,7 @@ import { type OrchestrationProposedPlan, type OrchestrationThread, type OrchestrationThreadActivity, + type ProviderRequestKind, type ProviderRuntimeEvent, } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; @@ -298,7 +299,7 @@ function sessionStatusAllowsActiveTurn( function requestKindFromCanonicalRequestType( requestType: string | undefined, -): "command" | "file-read" | "file-change" | undefined { +): ProviderRequestKind | undefined { switch (requestType) { case "command_execution_approval": case "exec_command_approval": @@ -308,6 +309,8 @@ function requestKindFromCanonicalRequestType( case "file_change_approval": case "apply_patch_approval": return "file-change"; + case "permission_approval": + return "permission"; default: return undefined; } @@ -388,7 +391,9 @@ export function runtimeEventToActivities( ? "File-read approval requested" : requestKind === "file-change" ? "File-change approval requested" - : "Approval requested", + : requestKind === "permission" + ? "App permission approval requested" + : "Approval requested", payload: { requestId: toApprovalRequestId(event.requestId), ...(requestKind ? { requestKind } : {}), diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 5358716aabe4..14f47a3e944a 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -693,6 +693,48 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("maps app permission approval requests to permission_approval request types", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-app-permission-request"), + kind: "request", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/permissions/requestApproval", + requestId: ApprovalRequestId.make("req-perm-1"), + requestKind: "permission", + turnId: asTurnId("turn-1"), + itemId: asItemId("app_1"), + payload: { + cwd: "/tmp/project", + itemId: "app_1", + permissions: { network: { enabled: true } }, + reason: "Fetch data from api.example.com", + startedAtMs: 1_778_000_000_000, + threadId: "thread-1", + turnId: "turn-1", + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + NodeAssert.equal(firstEvent.value.type, "request.opened"); + if (firstEvent.value.type !== "request.opened") { + return; + } + NodeAssert.equal(firstEvent.value.payload.requestType, "permission_approval"); + NodeAssert.equal(firstEvent.value.payload.detail, "Fetch data from api.example.com"); + }), + ); + it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 065156d36473..754b77a2dfa5 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -302,6 +302,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType { return "file_read_approval"; case "item/fileChange/requestApproval": return "file_change_approval"; + case "item/permissions/requestApproval": + return "permission_approval"; case "applyPatchApproval": return "apply_patch_approval"; case "execCommandApproval": @@ -325,6 +327,8 @@ function toRequestTypeFromKind(kind: ProviderRequestKind | undefined): Canonical return "file_read_approval"; case "file-change": return "file_change_approval"; + case "permission": + return "permission_approval"; default: return "unknown"; } @@ -819,6 +823,13 @@ function mapToRuntimeEvents( ); return payload?.reason ?? undefined; } + case "item/permissions/requestApproval": { + const payload = readPayload( + EffectCodexSchema.ServerRequest__PermissionsRequestApprovalParams, + event.payload, + ); + return payload?.reason ?? undefined; + } case "applyPatchApproval": { const payload = readPayload( EffectCodexSchema.ServerRequest__ApplyPatchApprovalParams, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 29bb992611c1..8dac49468002 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -1604,6 +1604,69 @@ export const makeCodexSessionRuntime = ( }), ); + yield* client.handleServerRequest("item/permissions/requestApproval", (payload) => + Effect.gen(function* () { + const requestId = ApprovalRequestId.make( + yield* randomUUIDv4("app-permission-approval-request"), + ); + const turnId = TurnId.make(payload.turnId); + const itemId = ProviderItemId.make(payload.itemId); + const decision = yield* Deferred.make(); + + yield* Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.set(requestId, { + requestId, + jsonRpcId: payload.itemId, + requestKind: "permission", + turnId, + itemId, + decision, + }); + return next; + }); + yield* Ref.update(approvalCorrelationsRef, (current) => { + const next = new Map(current); + next.set(payload.itemId, { + requestId, + requestKind: "permission", + turnId, + itemId, + }); + return next; + }); + + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "item/permissions/requestApproval", + requestId, + requestKind: "permission", + ...(turnId ? { turnId } : {}), + ...(itemId ? { itemId } : {}), + payload, + }); + + const resolved = yield* Deferred.await(decision).pipe( + Effect.ensuring( + Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }), + ), + ); + // Approving grants the requested profile; denying answers with an + // empty grant so the app-server treats the permission as withheld. + const grantedPermissions = + resolved === "accept" || resolved === "acceptForSession" ? payload.permissions : {}; + return { + permissions: grantedPermissions, + ...(resolved === "acceptForSession" ? { scope: "session" as const } : {}), + } satisfies EffectCodexSchema.PermissionsRequestApprovalResponse; + }), + ); + yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => Effect.gen(function* () { const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index d73f0f16b28f..53dc64b9b530 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -18,13 +18,17 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova ? "Command approval" : approval.requestKind === "file-read" ? "File read approval" - : "File change approval"; + : approval.requestKind === "permission" + ? "App permission approval" + : "File change approval"; const detailAriaLabel = approval.requestKind === "command" ? "Command" : approval.requestKind === "file-read" ? "File to read" - : "File change"; + : approval.requestKind === "permission" + ? "Permission request" + : "File change"; return (