| author | |
| committer | |
| log | efb96939fbb135b9f4de3bff19447200b178b23a |
| tree | 673649930de9e967cf3d857abe21baf2cd56b20e |
| parent | 82059df1523d070b00ab6f45159115f626a4977e |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 64 | import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle"; |
| 65 | 65 | import IconKeyboard from "@tabler/icons-react-native/IconKeyboard"; |
| 66 | 66 | import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide"; |
| 67 | import IconLock from "@tabler/icons-react-native/IconLock"; | |
| 67 | 68 | import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns"; |
| 68 | 69 | import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar"; |
| 69 | 70 | import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight"; |
| ... | ... | @@ -240,6 +241,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME = { |
| 240 | 241 | keyboard_arrow_down: IconChevronDown, |
| 241 | 242 | keyboard_arrow_up: IconChevronUp, |
| 242 | 243 | keyboard_hide: IconKeyboardHide, |
| 244 | lock: IconLock, | |
| 243 | 245 | more_vert: IconDotsVertical, |
| 244 | 246 | merge: IconGitMerge, |
| 245 | 247 | public: IconWorld, |
apps/mobile/src/features/threads/thread-work-log.tsx+2| ... | ... | @@ -363,6 +363,8 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { |
| 363 | 363 | return { ios: "globe", android: "public" }; |
| 364 | 364 | case "hammer": |
| 365 | 365 | return { ios: "hammer", android: "construction" }; |
| 366 | case "lock": | |
| 367 | return { ios: "lock", android: "lock" }; | |
| 366 | 368 | case "message": |
| 367 | 369 | return { ios: "bubble.left", android: "chat_bubble" }; |
| 368 | 370 | case "warning": |
apps/mobile/src/lib/threadActivity.ts+4-1| ... | ... | @@ -68,6 +68,7 @@ export interface ThreadFeedActivity { |
| 68 | 68 | | "eye" |
| 69 | 69 | | "globe" |
| 70 | 70 | | "hammer" |
| 71 | | "lock" | |
| 71 | 72 | | "message" |
| 72 | 73 | | "warning" |
| 73 | 74 | | "wrench" |
| ... | ... | @@ -971,6 +972,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { |
| 971 | 972 | if (entry.requestKind === "command") return "command"; |
| 972 | 973 | if (entry.requestKind === "file-read") return "eye"; |
| 973 | 974 | if (entry.requestKind === "file-change") return "edit"; |
| 975 | if (entry.requestKind === "permission") return "lock"; | |
| 974 | 976 | if (entry.itemType === "command_execution" || entry.command) return "command"; |
| 975 | 977 | if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit"; |
| 976 | 978 | if (entry.itemType === "web_search") return "globe"; |
| ... | ... | @@ -1439,7 +1441,8 @@ function extractWorkLogRequestKind( |
| 1439 | 1441 | if ( |
| 1440 | 1442 | payload?.requestKind === "command" || |
| 1441 | 1443 | payload?.requestKind === "file-read" || |
| 1442 | payload?.requestKind === "file-change" | |
| 1444 | payload?.requestKind === "file-change" || | |
| 1445 | payload?.requestKind === "permission" | |
| 1443 | 1446 | ) { |
| 1444 | 1447 | return payload.requestKind; |
| 1445 | 1448 | } |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+7-2| ... | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | type OrchestrationCheckpointSummary, |
| 15 | 15 | type OrchestrationThreadActivity, |
| 16 | 16 | type ProjectId, |
| 17 | type ProviderRequestKind, | |
| 17 | 18 | type ProviderRuntimeEvent, |
| 18 | 19 | type ResponseStreamingMode, |
| 19 | 20 | RuntimeRequestId, |
| ... | ... | @@ -400,7 +401,7 @@ function sessionStatusAllowsActiveTurn( |
| 400 | 401 | |
| 401 | 402 | function requestKindFromCanonicalRequestType( |
| 402 | 403 | requestType: string | undefined, |
| 403 | ): "command" | "file-read" | "file-change" | "mcp-elicitation" | undefined { | |
| 404 | ): ProviderRequestKind | undefined { | |
| 404 | 405 | switch (requestType) { |
| 405 | 406 | case "command_execution_approval": |
| 406 | 407 | case "exec_command_approval": |
| ... | ... | @@ -412,6 +413,8 @@ function requestKindFromCanonicalRequestType( |
| 412 | 413 | return "file-change"; |
| 413 | 414 | case "mcp_elicitation_approval": |
| 414 | 415 | return "mcp-elicitation"; |
| 416 | case "permission_approval": | |
| 417 | return "permission"; | |
| 415 | 418 | default: |
| 416 | 419 | return undefined; |
| 417 | 420 | } |
| ... | ... | @@ -494,7 +497,9 @@ export function runtimeEventToActivities( |
| 494 | 497 | ? "File-change approval requested" |
| 495 | 498 | : requestKind === "mcp-elicitation" |
| 496 | 499 | ? "App access approval requested" |
| 497 | : "Approval requested", | |
| 500 | : requestKind === "permission" | |
| 501 | ? "App permission approval requested" | |
| 502 | : "Approval requested", | |
| 498 | 503 | payload: { |
| 499 | 504 | requestId: toApprovalRequestId(event.requestId), |
| 500 | 505 | ...(requestKind ? { requestKind } : {}), |
apps/server/src/provider/Layers/CodexAdapter.test.ts+42| ... | ... | @@ -1719,6 +1719,48 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 1719 | 1719 | }), |
| 1720 | 1720 | ); |
| 1721 | 1721 | |
| 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 | 1764 | it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () => |
| 1723 | 1765 | Effect.gen(function* () { |
| 1724 | 1766 | const { adapter, runtime } = yield* startLifecycleRuntime(); |
apps/server/src/provider/Layers/CodexAdapter.ts+18| ... | ... | @@ -832,6 +832,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType { |
| 832 | 832 | return "file_change_approval"; |
| 833 | 833 | case "mcpServer/elicitation/request": |
| 834 | 834 | return "mcp_elicitation_approval"; |
| 835 | case "item/permissions/requestApproval": | |
| 836 | return "permission_approval"; | |
| 835 | 837 | case "applyPatchApproval": |
| 836 | 838 | return "apply_patch_approval"; |
| 837 | 839 | case "execCommandApproval": |
| ... | ... | @@ -857,6 +859,8 @@ function toRequestTypeFromKind(kind: ProviderRequestKind | undefined): Canonical |
| 857 | 859 | return "file_change_approval"; |
| 858 | 860 | case "mcp-elicitation": |
| 859 | 861 | return "mcp_elicitation_approval"; |
| 862 | case "permission": | |
| 863 | return "permission_approval"; | |
| 860 | 864 | default: |
| 861 | 865 | return "unknown"; |
| 862 | 866 | } |
| ... | ... | @@ -1366,6 +1370,20 @@ function mapToRuntimeEvents( |
| 1366 | 1370 | } |
| 1367 | 1371 | case "mcpServer/elicitation/request": |
| 1368 | 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 | 1387 | case "applyPatchApproval": { |
| 1370 | 1388 | const payload = readPayload( |
| 1371 | 1389 | EffectCodexSchema.ServerRequest__ApplyPatchApprovalParams, |
apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts+102| ... | ... | @@ -605,6 +605,108 @@ describe("CodexSessionRuntime collab integration", () => { |
| 605 | 605 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 606 | 606 | ); |
| 607 | 607 | |
| 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 | 710 | it.live("Stop targets the active turn when Codex has accepted a queued follow-up", () => |
| 609 | 711 | Effect.gen(function* () { |
| 610 | 712 | const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+73| ... | ... | @@ -2232,6 +2232,69 @@ export const makeCodexSessionRuntime = ( |
| 2232 | 2232 | }), |
| 2233 | 2233 | ); |
| 2234 | 2234 | |
| 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 | 2298 | yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => |
| 2236 | 2299 | Effect.gen(function* () { |
| 2237 | 2300 | const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); |
| ... | ... | @@ -2499,6 +2562,16 @@ export const makeCodexSessionRuntime = ( |
| 2499 | 2562 | Effect.gen(function* () { |
| 2500 | 2563 | const providerThreadId = yield* readProviderThreadId; |
| 2501 | 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 | 2575 | // Stop-everything: children are full threads with their own turns; |
| 2503 | 2576 | // interrupting only the parent leaves the fleet running. Interrupt |
| 2504 | 2577 | // 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 | 18 | const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); |
| 19 | 19 | let turnStartCount = 0; |
| 20 | 20 | let 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. | |
| 23 | const openServerRequests = new Map(); | |
| 22 | 24 | const rl = NodeReadline.createInterface({ input: process.stdin }); |
| 23 | 25 | rl.on("line", (line) => { |
| 24 | 26 | let message; |
| ... | ... | @@ -28,6 +30,30 @@ rl.on("line", (line) => { |
| 28 | 30 | return; |
| 29 | 31 | } |
| 30 | 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 | 57 | if (method === undefined && script.serverRequests?.some((request) => request.id === id)) { |
| 32 | 58 | NodeFS.appendFileSync( |
| 33 | 59 | `${process.env.T3_CODEX_COLLAB_SCRIPT}.responses`, |
| ... | ... | @@ -136,8 +162,24 @@ rl.on("line", (line) => { |
| 136 | 162 | for (const notification of script.notifications) { |
| 137 | 163 | write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); |
| 138 | 164 | } |
| 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 }); | |
| 141 | 183 | } |
| 142 | 184 | if (script.holdTurnOpen !== true) { |
| 143 | 185 | write({ |
apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx+6-2| ... | ... | @@ -21,7 +21,9 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova |
| 21 | 21 | ? "Command approval" |
| 22 | 22 | : approval.requestKind === "file-read" |
| 23 | 23 | ? "File read approval" |
| 24 | : "File change approval"; | |
| 24 | : approval.requestKind === "permission" | |
| 25 | ? "App permission approval" | |
| 26 | : "File change approval"; | |
| 25 | 27 | const detailAriaLabel = |
| 26 | 28 | approval.requestKind === "mcp-elicitation" |
| 27 | 29 | ? "App access request" |
| ... | ... | @@ -29,7 +31,9 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova |
| 29 | 31 | ? "Command" |
| 30 | 32 | : approval.requestKind === "file-read" |
| 31 | 33 | ? "File to read" |
| 32 | : "File change"; | |
| 34 | : approval.requestKind === "permission" | |
| 35 | ? "Permission request" | |
| 36 | : "File change"; | |
| 33 | 37 | |
| 34 | 38 | return ( |
| 35 | 39 | <span |
apps/web/src/session-logic.ts+2-1| ... | ... | @@ -1316,7 +1316,8 @@ function extractWorkLogRequestKind( |
| 1316 | 1316 | if ( |
| 1317 | 1317 | payload?.requestKind === "command" || |
| 1318 | 1318 | payload?.requestKind === "file-read" || |
| 1319 | payload?.requestKind === "file-change" | |
| 1319 | payload?.requestKind === "file-change" || | |
| 1320 | payload?.requestKind === "permission" | |
| 1320 | 1321 | ) { |
| 1321 | 1322 | return payload.requestKind; |
| 1322 | 1323 | } |
packages/client-runtime/src/pendingRequests.ts+2| ... | ... | @@ -59,6 +59,8 @@ export function requestKindFromRequestType(requestType: unknown): ProviderReques |
| 59 | 59 | return "file-change"; |
| 60 | 60 | case "mcp_elicitation_approval": |
| 61 | 61 | return "mcp-elicitation"; |
| 62 | case "permission_approval": | |
| 63 | return "permission"; | |
| 62 | 64 | default: |
| 63 | 65 | return null; |
| 64 | 66 | } |
packages/contracts/src/orchestration.ts+1| ... | ... | @@ -141,6 +141,7 @@ export const ProviderRequestKind = Schema.Literals([ |
| 141 | 141 | "file-read", |
| 142 | 142 | "file-change", |
| 143 | 143 | "mcp-elicitation", |
| 144 | "permission", | |
| 144 | 145 | ]); |
| 145 | 146 | export type ProviderRequestKind = typeof ProviderRequestKind.Type; |
| 146 | 147 | export const ProviderApprovalDecision = Schema.Literals([ |
packages/contracts/src/providerRuntime.ts+1| ... | ... | @@ -141,6 +141,7 @@ export const CanonicalRequestType = Schema.Literals([ |
| 141 | 141 | "apply_patch_approval", |
| 142 | 142 | "exec_command_approval", |
| 143 | 143 | "mcp_elicitation_approval", |
| 144 | "permission_approval", | |
| 144 | 145 | "tool_user_input", |
| 145 | 146 | "dynamic_tool_call", |
| 146 | 147 | "auth_tokens_refresh", |
packages/effect-codex-app-server/src/errors.ts+1| ... | ... | @@ -118,6 +118,7 @@ export const CodexAppServerIdentifierPurpose = Schema.Literals([ |
| 118 | 118 | "command-approval-request", |
| 119 | 119 | "file-change-approval-request", |
| 120 | 120 | "mcp-elicitation-request", |
| 121 | "app-permission-approval-request", | |
| 121 | 122 | "user-input-request", |
| 122 | 123 | ]); |
| 123 | 124 | export type CodexAppServerIdentifierPurpose = typeof CodexAppServerIdentifierPurpose.Type; |