authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-09-04 02:08:12+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 20:38:12+00:00
logd2b6f3b9296f682c6158b894ab33d98d0c4bfb2b
tree80cf1ba5a38254049abdf37a46cdf196a5da4664
parent36c4e9cf5c0123e33d65f2af9497ee090404b532
signature Signed by PGP key B5690EEEBB952194

fix(server): full-access OpenCode threads no longer ask for approvals (#9282)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

3 files changed, 277 insertions(+), 7 deletions(-)

apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+207
......@@ -89,6 +89,7 @@ const runtimeMock = {
8989 subscribedEvents: [] as Array<unknown | Promise<unknown>>,
9090 eventSubscribeObserved: null as (() => void) | null,
9191 permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>,
92 permissionReplyImplementation: null as (() => Promise<void>) | null,
9293 questionReplyCalls: [] as Array<{
9394 requestID: string;
9495 answers: ReadonlyArray<ReadonlyArray<string>>;
......@@ -139,6 +140,7 @@ const runtimeMock = {
139140 this.state.subscribedEvents = [];
140141 this.state.eventSubscribeObserved = null;
141142 this.state.permissionReplyCalls.length = 0;
143 this.state.permissionReplyImplementation = null;
142144 this.state.questionReplyCalls.length = 0;
143145 this.state.sessionStatus = "idle";
144146 this.state.sessionStatusFailures = 0;
......@@ -377,6 +379,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
377379 },
378380 reply: async ({ requestID, reply }: { requestID: string; reply: string }) => {
379381 runtimeMock.state.permissionReplyCalls.push({ requestID, reply });
382 if (runtimeMock.state.permissionReplyImplementation) {
383 await runtimeMock.state.permissionReplyImplementation();
384 }
380385 },
381386 },
382387 question: {
......@@ -2623,6 +2628,208 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
26232628 }),
26242629 );
26252630
2631 it.effect.each([
2632 {
2633 name: "a doom-loop ask on the parent session",
2634 requestId: "per_doom_loop",
2635 sessionID: "http://127.0.0.1:9999/session",
2636 permission: "doom_loop",
2637 patterns: ["bash"],
2638 always: [] as string[],
2639 },
2640 {
2641 name: "a child-session ask",
2642 requestId: "per_child_full",
2643 sessionID: "ses_child_full",
2644 permission: "read",
2645 patterns: ["/repo/settings.env"],
2646 always: ["/repo/settings.env"],
2647 },
2648 ])(
2649 "auto-approves $name in full access",
2650 ({ requestId, sessionID, permission, patterns, always }) =>
2651 Effect.gen(function* () {
2652 const adapter = yield* OpenCodeAdapter;
2653 const threadId = asThreadId(`thread-full-access-${requestId}`);
2654 runtimeMock.state.subscribedEvents = [
2655 {
2656 id: "evt-child-created",
2657 type: "session.created",
2658 properties: {
2659 sessionID: "ses_child_full",
2660 info: {
2661 id: "ses_child_full",
2662 parentID: "http://127.0.0.1:9999/session",
2663 title: "Child session",
2664 },
2665 },
2666 },
2667 {
2668 id: "evt-permission",
2669 type: "permission.asked",
2670 properties: { id: requestId, sessionID, permission, patterns, metadata: {}, always },
2671 },
2672 {
2673 id: "evt-permission-replied",
2674 type: "permission.replied",
2675 properties: { sessionID, requestID: requestId, reply: "once" },
2676 },
2677 // The suppressed ask emits nothing, so an empty question serves as a
2678 // sentinel that closes the collected stream once the pump is past it.
2679 {
2680 id: "evt-sentinel-question",
2681 type: "question.asked",
2682 properties: {
2683 id: "que_sentinel",
2684 sessionID: "http://127.0.0.1:9999/session",
2685 questions: [],
2686 },
2687 },
2688 ];
2689
2690 const eventsFiber = yield* adapter.streamEvents.pipe(
2691 Stream.filter((event) => event.threadId === threadId),
2692 Stream.takeUntil((event) => event.type === "user-input.requested"),
2693 Stream.runCollect,
2694 Effect.forkChild,
2695 );
2696 yield* adapter.startSession({
2697 provider: ProviderDriverKind.make("opencode"),
2698 threadId,
2699 runtimeMode: "full-access",
2700 });
2701 const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
2702
2703 NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [
2704 { requestID: requestId, reply: "once" },
2705 ]);
2706 NodeAssert.equal(
2707 events.some((event) => event.type === "request.opened"),
2708 false,
2709 );
2710 NodeAssert.equal(
2711 events.some((event) => event.type === "request.resolved"),
2712 false,
2713 );
2714
2715 yield* adapter.stopSession(threadId);
2716 }),
2717 );
2718
2719 it.effect("surfaces the approval when the full-access auto-reply fails", () =>
2720 Effect.gen(function* () {
2721 const adapter = yield* OpenCodeAdapter;
2722 const threadId = asThreadId("thread-full-access-reply-failed");
2723 runtimeMock.state.permissionReplyImplementation = async () => {
2724 throw new Error("reply failed");
2725 };
2726 runtimeMock.state.subscribedEvents = [
2727 {
2728 id: "evt-doom-loop",
2729 type: "permission.asked",
2730 properties: {
2731 id: "per_doom_loop_failed",
2732 sessionID: "http://127.0.0.1:9999/session",
2733 permission: "doom_loop",
2734 patterns: ["bash"],
2735 metadata: {},
2736 always: [],
2737 },
2738 },
2739 ];
2740
2741 const openedFiber = yield* adapter.streamEvents.pipe(
2742 Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"),
2743 Stream.take(1),
2744 Stream.runHead,
2745 Effect.forkChild,
2746 );
2747 yield* adapter.startSession({
2748 provider: ProviderDriverKind.make("opencode"),
2749 threadId,
2750 runtimeMode: "full-access",
2751 });
2752 const opened = Option.getOrUndefined(
2753 yield* Fiber.join(openedFiber).pipe(Effect.timeout("1 second")),
2754 );
2755 NodeAssert.equal(opened?.requestId, "per_doom_loop_failed");
2756 // Exactly one auto-reply attempt: the fallback surfaces the dialog
2757 // instead of retrying the reply.
2758 NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [
2759 { requestID: "per_doom_loop_failed", reply: "once" },
2760 ]);
2761
2762 yield* adapter.stopSession(threadId);
2763 }),
2764 );
2765
2766 it.effect("does not reopen a failed full-access auto-reply after its terminal reply", () =>
2767 Effect.gen(function* () {
2768 const adapter = yield* OpenCodeAdapter;
2769 const threadId = asThreadId("thread-full-access-reply-failed-after-terminal");
2770 const childId = "ses_full_access_terminal_child";
2771 const request = permissionRequest("per_failed_after_terminal", childId);
2772 const ancestryAttempted = promiseWithResolvers<void>();
2773 const releaseReply = promiseWithResolvers<void>();
2774 // The ask arrives from a child whose ancestry lookup is failing, so it
2775 // is handled on a retry fiber. The terminal reply lands while that
2776 // fiber's auto-reply is still in flight; the reply then fails. The
2777 // request must neither reopen nor emit a stray resolution.
2778 runtimeMock.state.sessionParentById.set(childId, "http://127.0.0.1:9999/session");
2779 runtimeMock.state.transientErrorSessionIds.add(childId);
2780 runtimeMock.state.sessionGetObserved = (sessionID) => {
2781 if (sessionID === childId) {
2782 ancestryAttempted.resolve(undefined);
2783 }
2784 };
2785 runtimeMock.state.permissionReplyImplementation = async () => {
2786 await releaseReply.promise;
2787 throw new Error("reply failed");
2788 };
2789 const terminalEvent = promiseWithResolvers<unknown>();
2790 runtimeMock.state.subscribedEvents = [
2791 { id: "evt-ask", type: "permission.asked", properties: request },
2792 terminalEvent.promise,
2793 ];
2794
2795 const requestEventsFiber = yield* adapter.streamEvents.pipe(
2796 Stream.filter(
2797 (event) =>
2798 event.threadId === threadId &&
2799 (event.type === "request.opened" || event.type === "request.resolved"),
2800 ),
2801 Stream.runHead,
2802 Effect.forkChild,
2803 );
2804 yield* adapter.startSession({
2805 provider: ProviderDriverKind.make("opencode"),
2806 threadId,
2807 runtimeMode: "full-access",
2808 });
2809 yield* Effect.promise(() => ancestryAttempted.promise);
2810 runtimeMock.state.transientErrorSessionIds.delete(childId);
2811 yield* advanceTestClock(250);
2812 NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [
2813 { requestID: request.id, reply: "once" },
2814 ]);
2815
2816 // Drain the microtask queue so the pump has consumed the terminal reply
2817 // before the in-flight auto-reply is allowed to fail.
2818 terminalEvent.resolve({
2819 id: "evt-reply",
2820 type: "permission.replied",
2821 properties: { sessionID: childId, requestID: request.id, reply: "once" },
2822 });
2823 yield* Effect.promise(() => new Promise<void>((resolve) => setImmediate(resolve)));
2824 releaseReply.resolve(undefined);
2825 yield* advanceTestClock(250);
2826
2827 NodeAssert.equal(requestEventsFiber.pollUnsafe(), undefined);
2828 yield* Fiber.interrupt(requestEventsFiber);
2829 yield* adapter.stopSession(threadId);
2830 }),
2831 );
2832
26262833 it.effect("routes child-session questions and replies through the parent thread", () =>
26272834 Effect.gen(function* () {
26282835 const adapter = yield* OpenCodeAdapter;
apps/server/src/provider/Layers/OpenCodeAdapter.ts+64-7
......@@ -328,6 +328,7 @@ interface OpenCodeSessionContext {
328328 readonly openCodeSessionId: string;
329329 readonly relatedSessionIds: Set<string>;
330330 readonly resolvedRequestIds: Set<string>;
331 readonly autoRepliedRequestIds: Set<string>;
331332 readonly emittedTerminalRequestIds: Set<string>;
332333 readonly requestRelationRetries: Map<string, OpenCodeRequestRelationRetry>;
333334 readonly pendingPermissions: Map<string, PermissionRequest>;
......@@ -1000,6 +1001,12 @@ export function makeOpenCodeAdapter(
10001001
10011002 const emit = (event: ProviderRuntimeEvent) =>
10021003 Queue.offer(runtimeEvents, event).pipe(Effect.asVoid);
1004 // Synchronous publish for callers that must not yield between a state
1005 // check and the enqueue, e.g. reopening an approval only if its terminal
1006 // event has not landed yet.
1007 const emitUnsafe = (event: ProviderRuntimeEvent) => {
1008 Queue.offerUnsafe(runtimeEvents, event);
1009 };
10031010 const writeNativeEvent = (
10041011 threadId: ThreadId,
10051012 event: {
......@@ -1602,6 +1609,39 @@ export function makeOpenCodeAdapter(
16021609 return false;
16031610 });
16041611
1612 // Full access means the user already granted everything, but two upstream
1613 // paths never consult the session ruleset we send: doom-loop detection
1614 // (evaluated against the agent ruleset only) and subagent sessions (which
1615 // keep only deny and external-directory rules). Answer those asks here.
1616 //
1617 // Reply "once", not "always": OpenCode stores "always" grants per
1618 // directory, so on a shared external server an "always" from a full-access
1619 // thread would silently widen what a supervised thread on the same
1620 // directory is allowed to do.
1621 const autoReplyFullAccess = Effect.fn("autoReplyFullAccess")(function* (
1622 context: OpenCodeSessionContext,
1623 request: PermissionRequest,
1624 ) {
1625 // Mark before awaiting: retry and recovery fibers re-enter the ask path,
1626 // and the matching `permission.replied` can arrive, while the SDK call
1627 // is in flight. Marked ids skip the ask and swallow the terminal event.
1628 context.resolvedRequestIds.add(request.id);
1629 context.autoRepliedRequestIds.add(request.id);
1630 const replied = yield* runOpenCodeSdk("permission.reply", () =>
1631 context.client.permission.reply({ requestID: request.id, reply: "once" }),
1632 ).pipe(
1633 Effect.as(true),
1634 Effect.orElseSucceed(() => false),
1635 );
1636 if (!replied) {
1637 // Fall back to the dialog. The id stays resolved so a recovered copy
1638 // of this ask cannot reopen after the user answers;
1639 // `pendingPermissions` gates re-asks while the dialog is open.
1640 context.autoRepliedRequestIds.delete(request.id);
1641 }
1642 return replied;
1643 });
1644
16051645 const emitPendingOpenCodeRequest = Effect.fn("emitPendingOpenCodeRequest")(function* (
16061646 context: OpenCodeSessionContext,
16071647 event: OpenCodeAskedRequestEvent,
......@@ -1615,14 +1655,27 @@ export function makeOpenCodeAdapter(
16151655 if (context.pendingPermissions.has(request.id)) {
16161656 return;
16171657 }
1658 if (
1659 context.session.runtimeMode === "full-access" &&
1660 (yield* autoReplyFullAccess(context, request))
1661 ) {
1662 return;
1663 }
1664 const base = yield* buildEventBase({
1665 threadId: context.session.threadId,
1666 turnId: context.activeTurnId,
1667 requestId: request.id,
1668 raw,
1669 });
1670 // No yield between this check and the publish: a terminal
1671 // `permission.replied` delivered on the pump in between would leave a
1672 // dialog that can never close.
1673 if (context.emittedTerminalRequestIds.has(request.id)) {
1674 return;
1675 }
16181676 context.pendingPermissions.set(request.id, request);
1619 yield* emit({
1620 ...(yield* buildEventBase({
1621 threadId: context.session.threadId,
1622 turnId: context.activeTurnId,
1623 requestId: request.id,
1624 raw,
1625 })),
1677 emitUnsafe({
1678 ...base,
16261679 type: "request.opened",
16271680 payload: {
16281681 requestType: mapPermissionToRequestType(request.permission),
......@@ -1671,6 +1724,9 @@ export function makeOpenCodeAdapter(
16711724 return;
16721725 }
16731726 context.emittedTerminalRequestIds.add(requestId);
1727 if (context.autoRepliedRequestIds.delete(requestId)) {
1728 return;
1729 }
16741730 if (event.type === "permission.replied") {
16751731 yield* emit({
16761732 ...(yield* buildEventBase({
......@@ -2554,6 +2610,7 @@ export function makeOpenCodeAdapter(
25542610 openCodeSessionId: started.openCodeSession.id,
25552611 relatedSessionIds: new Set([started.openCodeSession.id]),
25562612 resolvedRequestIds: new Set(),
2613 autoRepliedRequestIds: new Set(),
25572614 emittedTerminalRequestIds: new Set(),
25582615 requestRelationRetries: new Map(),
25592616 pendingPermissions: new Map(),
docs/internals/providers.md+6
......@@ -214,6 +214,12 @@ connection, while OpenCode stores MCP connections by directory. Sharing these ch
214214without changing MCP routing would let two threads in one directory replace each other's
215215connection.
216216
217Chat adapters send the runtime mode as a session ruleset, but upstream OpenCode evaluates
218doom-loop and subagent asks against the agent ruleset only. In full access the adapter answers
219those asks itself so the user never sees an approval they already granted. It replies `once`
220rather than `always` because OpenCode stores `always` grants per directory, and on a shared
221external server that would widen what a supervised thread in the same directory may do.
222
217223OpenCode loads its catalog through the HTTP API when an enabled provider instance starts. The
218224provider registry keeps the snapshot in memory and persists it in the existing per-instance cache.
219225Each `subscribeServerConfig` connection refreshes all providers, so a client reconnect reloads the