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 fbde33da8514..4521d048c057 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; } @@ -1378,7 +1383,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 26fb1b166f61..8e0e99d890cd 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -858,6 +858,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 bc48f94b3866..f465ec98b2ea 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"; } @@ -816,6 +820,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/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index a1b46e003520..d49a4cdd7ebe 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -13,7 +13,8 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { ThreadId } from "@t3tools/contracts"; +import { type ProviderEvent, ThreadId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Stream from "effect/Stream"; @@ -280,6 +281,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 fd926e43d7bf..1d390a7eb0f8 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -1607,6 +1607,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")); @@ -1867,6 +1930,14 @@ 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"); // 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 f06e984c9aa5..161402b91834 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -17,6 +17,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; +// 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) => { @@ -27,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 === "initialize") { write({ id, @@ -61,6 +88,20 @@ rl.on("line", (line) => { for (const notification of script.notifications) { write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); } + // 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. + for (const [index, serverRequest] of (script.serverRequests ?? []).entries()) { + const requestId = 9000 + index; + const label = serverRequest.label ?? `approval-${requestId}`; + openServerRequests.set(requestId, { label, itemId: serverRequest.params?.itemId }); + const params = JSON.parse( + JSON.stringify(serverRequest.params) + .replaceAll("${threadId}", String(rootThreadId)) + .replaceAll("${turnId}", String(turn.id)), + ); + write({ jsonrpc: "2.0", id: requestId, method: serverRequest.method, params }); + } if (script.holdTurnOpen !== true) { write({ jsonrpc: "2.0", 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 (