diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 5540d5b5dfb85da42aaf4fef7b69925753e54717..345ace389cc7f0ad1e886f38343c66de19ebd140 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -64,6 +64,7 @@ import IconHome from "@tabler/icons-react-native/IconHome"; import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle"; import IconKeyboard from "@tabler/icons-react-native/IconKeyboard"; import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide"; +import IconLock from "@tabler/icons-react-native/IconLock"; import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns"; import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar"; import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight"; @@ -240,6 +241,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME = { keyboard_arrow_down: IconChevronDown, keyboard_arrow_up: IconChevronUp, keyboard_hide: IconKeyboardHide, + lock: IconLock, more_vert: IconDotsVertical, merge: IconGitMerge, public: IconWorld, diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index d227a72a2c7e383888086c9a5ea905f11ef6823c..a2053a2a67b30545465e77d5c6d038e14e23928e 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -363,6 +363,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 887be4a9272dd90d07c57fea4fb523adf4f38909..8f000b1b55fc517a54756fda67abda7335881201 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -68,6 +68,7 @@ export interface ThreadFeedActivity { | "eye" | "globe" | "hammer" + | "lock" | "message" | "warning" | "wrench" @@ -971,6 +972,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"; @@ -1439,7 +1441,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; } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 84cb34a4e7834d2530810835b23fabfe273594d7..0db70e491235185e3fcfe7001c91c0ba01075c1a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -14,6 +14,7 @@ import { type OrchestrationCheckpointSummary, type OrchestrationThreadActivity, type ProjectId, + type ProviderRequestKind, type ProviderRuntimeEvent, type ResponseStreamingMode, RuntimeRequestId, @@ -400,7 +401,7 @@ function sessionStatusAllowsActiveTurn( function requestKindFromCanonicalRequestType( requestType: string | undefined, -): "command" | "file-read" | "file-change" | "mcp-elicitation" | undefined { +): ProviderRequestKind | undefined { switch (requestType) { case "command_execution_approval": case "exec_command_approval": @@ -412,6 +413,8 @@ function requestKindFromCanonicalRequestType( return "file-change"; case "mcp_elicitation_approval": return "mcp-elicitation"; + case "permission_approval": + return "permission"; default: return undefined; } @@ -494,7 +497,9 @@ export function runtimeEventToActivities( ? "File-change approval requested" : requestKind === "mcp-elicitation" ? "App access 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 48aff6cd707e74361eb3cda1d92a8e69d332506e..9f464bdaa1779ee0e13dd0b5b5d1cf125523131a 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -1719,6 +1719,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 ba61d14f5cf56ca4c2025f82151f3e11de8d9389..0ecc9693ab04399c8eb1898f1eedaad1e421289e 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -832,6 +832,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType { return "file_change_approval"; case "mcpServer/elicitation/request": return "mcp_elicitation_approval"; + case "item/permissions/requestApproval": + return "permission_approval"; case "applyPatchApproval": return "apply_patch_approval"; case "execCommandApproval": @@ -857,6 +859,8 @@ function toRequestTypeFromKind(kind: ProviderRequestKind | undefined): Canonical return "file_change_approval"; case "mcp-elicitation": return "mcp_elicitation_approval"; + case "permission": + return "permission_approval"; default: return "unknown"; } @@ -1366,6 +1370,20 @@ function mapToRuntimeEvents( } case "mcpServer/elicitation/request": return elicitation?.message; + case "item/permissions/requestApproval": { + const payload = readPayload( + EffectCodexSchema.ServerRequest__PermissionsRequestApprovalParams, + event.payload, + ); + const requestedPaths = [ + ...(payload?.permissions.fileSystem?.read ?? []), + ...(payload?.permissions.fileSystem?.write ?? []), + ]; + return ( + nonEmptyDetail(payload?.reason) ?? + (requestedPaths.length > 0 ? `Access: ${requestedPaths.join(", ")}` : undefined) + ); + } case "applyPatchApproval": { const payload = readPayload( EffectCodexSchema.ServerRequest__ApplyPatchApprovalParams, diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 02b7a45f33adedd616f116570edfa6a98c93198e..2a9fb56c186a5d9e33b5d09eb0d1fc728acc8bc2 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -605,6 +605,108 @@ describe("CodexSessionRuntime collab integration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + // it.live: the runtime talks to a real child process; under it.effect's + // TestClock the internal timers freeze and the join never completes. + it.live("Stop answers a parked app-permission approval with a withheld grant", () => + Effect.gen(function* () { + // Interrupting a turn whose app-permission prompt is still parked must + // settle that prompt: the handler resumes with "cancel", the peer gets + // an empty grant (permission withheld), and nothing hangs until close. + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + notifications: [], + serverRequests: [ + { + method: "item/permissions/requestApproval", + label: "perm-1", + params: { + cwd: "/tmp/project", + itemId: "app_1", + permissions: { network: { enabled: true } }, + reason: "Fetch data from api.example.com", + startedAtMs: 1_778_000_000_000, + threadId: "${threadId}", + turnId: "${turnId}", + }, + }, + ], + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + const responsesPath = `${scriptPath}.approvalResponses`; + NodeFS.rmSync(responsesPath, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(responsesPath, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-permission-stop"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + // One consumer for the whole stream: `events` is a plain queue stream, + // so two forks would compete for events and each could starve the + // other's filter. Signal the two milestones through Deferreds instead. + const requestedReady = yield* Deferred.make(); + const settledReady = yield* Deferred.make(); + yield* runtime.events.pipe( + Stream.runForEach((event) => { + if (event.method === "item/permissions/requestApproval") { + return Deferred.succeed(requestedReady, event); + } + if (event.method === "serverRequest/resolved" && event.requestKind === "permission") { + return Deferred.succeed(settledReady, event); + } + return Effect.void; + }), + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "use the connected app" }); + const requested = yield* Deferred.await(requestedReady).pipe( + Effect.timeoutOption("15 seconds"), + ); + assert.isTrue(requested._tag === "Some", "permission approval request never arrived"); + + yield* runtime.interruptTurn(); + + // The peer emits serverRequest/resolved only AFTER recording the + // runtime's answer, so awaiting this receipt makes reading the sidecar + // race-free. The runtime correlates that receipt back to the canonical + // request (requestKind + requestId) — the same event chain the adapter + // folds into approval.resolved, so the card actually closes. + const settled = yield* Deferred.await(settledReady).pipe(Effect.timeoutOption("15 seconds")); + assert.isTrue(settled._tag === "Some", "interrupt did not settle the parked approval"); + const settledEvent = settled._tag === "Some" ? settled.value : undefined; + assert.isDefined(settledEvent); + assert.isDefined( + settledEvent?.requestId, + "receipt must correlate back to the canonical approval request", + ); + + const recorded = NodeFS.readFileSync(responsesPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { id: number; label: string; result: unknown }); + assert.equal(recorded.length, 1); + const answer = recorded[0]; + assert.isDefined(answer); + assert.equal(answer.label, "perm-1"); + // Cancelled approvals withhold the grant: an empty permission profile. + assert.deepEqual(answer.result, { permissions: {} }); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.live("Stop targets the active turn when Codex has accepted a queued follow-up", () => Effect.gen(function* () { const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index c8aa069cb2b70f596f34b094bbcae20ca54ab395..674d23327b65a6bb2fc6e54a25b2f6d0a8ee859d 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -2232,6 +2232,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")); @@ -2499,6 +2562,16 @@ export const makeCodexSessionRuntime = ( Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; const session = yield* Ref.get(sessionRef); + // Settle parked approvals FIRST. The transport answers server + // requests inline on its stdin read loop, so a pending + // command/file/app-permission prompt blocks every incoming message, + // including the turn/interrupt response itself - cancelling after + // the RPC would deadlock Stop exactly when a card is open. Settling + // releases the handler, which answers the peer and unblocks the + // loop before the interrupts below are sent. + yield* settlePendingApprovals("cancel"); + // Pending user-input prompts block the same way; settle them too. + yield* settlePendingUserInputs({}); // Stop-everything: children are full threads with their own turns; // interrupting only the parent leaves the fleet running. Interrupt // each live child turn first, best-effort per child, BOUNDED: the diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index fa567d75cf8a8fa6291f7d821816599b410df617..440f1558a68406cb2e0b09aef675f345b2197c14 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -18,7 +18,9 @@ const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); let turnStartCount = 0; let activeTurn; - +// Server->client requests the runtime must answer (approval prompts), keyed +// by the numeric JSON-RPC id this peer allocated for them. +const openServerRequests = new Map(); const rl = NodeReadline.createInterface({ input: process.stdin }); rl.on("line", (line) => { let message; @@ -28,6 +30,30 @@ rl.on("line", (line) => { return; } const { id, method } = message; + if (openServerRequests.has(id)) { + // The runtime answered an approval request. Record the response so tests + // can assert settlement behavior, then emit serverRequest/resolved as a + // deterministic receipt (tests wait on the runtime's event stream rather + // than polling the sidecar file). Real codex identifies the resolved + // request by its item id — the key the runtime correlates on — so the + // receipt exercises the same path that closes the approval card in the + // UI. + const request = openServerRequests.get(id); + openServerRequests.delete(id); + NodeFS.appendFileSync( + `${process.env.T3_CODEX_COLLAB_SCRIPT}.approvalResponses`, + `${JSON.stringify({ id, label: request.label, result: message.result ?? null })}\n`, + ); + write({ + jsonrpc: "2.0", + method: "serverRequest/resolved", + params: { + threadId: script.rootThreadId, + requestId: request.itemId ?? request.label, + }, + }); + return; + } if (method === undefined && script.serverRequests?.some((request) => request.id === id)) { NodeFS.appendFileSync( `${process.env.T3_CODEX_COLLAB_SCRIPT}.responses`, @@ -136,8 +162,24 @@ rl.on("line", (line) => { for (const notification of script.notifications) { write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); } - for (const request of script.serverRequests ?? []) { - write({ jsonrpc: "2.0", id: request.id, method: request.method, params: request.params }); + for (const [index, request] of (script.serverRequests ?? []).entries()) { + if (request.id !== undefined) { + // Legacy form: caller-supplied JSON-RPC ids recorded in .responses. + write({ jsonrpc: "2.0", id: request.id, method: request.method, params: request.params }); + continue; + } + // Scripted server->client requests (approval prompts). String values in + // params may reference "${threadId}" / "${turnId}" placeholders that are + // substituted with the ids this peer actually allocated. + const requestId = 9000 + index; + const label = request.label ?? `approval-${requestId}`; + openServerRequests.set(requestId, { label, itemId: request.params?.itemId }); + const params = JSON.parse( + JSON.stringify(request.params) + .replaceAll("${threadId}", String(rootThreadId)) + .replaceAll("${turnId}", String(turn.id)), + ); + write({ jsonrpc: "2.0", id: requestId, method: request.method, params }); } if (script.holdTurnOpen !== true) { write({ diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index 8065556cebc5f2134cfd6c3e96936ff6d4fdff24..5f181e71783ded9b2d29d48429fae782413fe689 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -21,7 +21,9 @@ 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 === "mcp-elicitation" ? "App access request" @@ -29,7 +31,9 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova ? "Command" : approval.requestKind === "file-read" ? "File to read" - : "File change"; + : approval.requestKind === "permission" + ? "Permission request" + : "File change"; return (