diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 7f327cae8fb3..01baf92db73e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -89,6 +89,7 @@ const runtimeMock = { subscribedEvents: [] as Array>, eventSubscribeObserved: null as (() => void) | null, permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>, + permissionReplyImplementation: null as (() => Promise) | null, questionReplyCalls: [] as Array<{ requestID: string; answers: ReadonlyArray>; @@ -139,6 +140,7 @@ const runtimeMock = { this.state.subscribedEvents = []; this.state.eventSubscribeObserved = null; this.state.permissionReplyCalls.length = 0; + this.state.permissionReplyImplementation = null; this.state.questionReplyCalls.length = 0; this.state.sessionStatus = "idle"; this.state.sessionStatusFailures = 0; @@ -377,6 +379,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }, reply: async ({ requestID, reply }: { requestID: string; reply: string }) => { runtimeMock.state.permissionReplyCalls.push({ requestID, reply }); + if (runtimeMock.state.permissionReplyImplementation) { + await runtimeMock.state.permissionReplyImplementation(); + } }, }, question: { @@ -2623,6 +2628,208 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect.each([ + { + name: "a doom-loop ask on the parent session", + requestId: "per_doom_loop", + sessionID: "http://127.0.0.1:9999/session", + permission: "doom_loop", + patterns: ["bash"], + always: [] as string[], + }, + { + name: "a child-session ask", + requestId: "per_child_full", + sessionID: "ses_child_full", + permission: "read", + patterns: ["/repo/settings.env"], + always: ["/repo/settings.env"], + }, + ])( + "auto-approves $name in full access", + ({ requestId, sessionID, permission, patterns, always }) => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId(`thread-full-access-${requestId}`); + runtimeMock.state.subscribedEvents = [ + { + id: "evt-child-created", + type: "session.created", + properties: { + sessionID: "ses_child_full", + info: { + id: "ses_child_full", + parentID: "http://127.0.0.1:9999/session", + title: "Child session", + }, + }, + }, + { + id: "evt-permission", + type: "permission.asked", + properties: { id: requestId, sessionID, permission, patterns, metadata: {}, always }, + }, + { + id: "evt-permission-replied", + type: "permission.replied", + properties: { sessionID, requestID: requestId, reply: "once" }, + }, + // The suppressed ask emits nothing, so an empty question serves as a + // sentinel that closes the collected stream once the pump is past it. + { + id: "evt-sentinel-question", + type: "question.asked", + properties: { + id: "que_sentinel", + sessionID: "http://127.0.0.1:9999/session", + questions: [], + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "user-input.requested"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: requestId, reply: "once" }, + ]); + NodeAssert.equal( + events.some((event) => event.type === "request.opened"), + false, + ); + NodeAssert.equal( + events.some((event) => event.type === "request.resolved"), + false, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("surfaces the approval when the full-access auto-reply fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-full-access-reply-failed"); + runtimeMock.state.permissionReplyImplementation = async () => { + throw new Error("reply failed"); + }; + runtimeMock.state.subscribedEvents = [ + { + id: "evt-doom-loop", + type: "permission.asked", + properties: { + id: "per_doom_loop_failed", + sessionID: "http://127.0.0.1:9999/session", + permission: "doom_loop", + patterns: ["bash"], + metadata: {}, + always: [], + }, + }, + ]; + + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.take(1), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const opened = Option.getOrUndefined( + yield* Fiber.join(openedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(opened?.requestId, "per_doom_loop_failed"); + // Exactly one auto-reply attempt: the fallback surfaces the dialog + // instead of retrying the reply. + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: "per_doom_loop_failed", reply: "once" }, + ]); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not reopen a failed full-access auto-reply after its terminal reply", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-full-access-reply-failed-after-terminal"); + const childId = "ses_full_access_terminal_child"; + const request = permissionRequest("per_failed_after_terminal", childId); + const ancestryAttempted = promiseWithResolvers(); + const releaseReply = promiseWithResolvers(); + // The ask arrives from a child whose ancestry lookup is failing, so it + // is handled on a retry fiber. The terminal reply lands while that + // fiber's auto-reply is still in flight; the reply then fails. The + // request must neither reopen nor emit a stray resolution. + runtimeMock.state.sessionParentById.set(childId, "http://127.0.0.1:9999/session"); + runtimeMock.state.transientErrorSessionIds.add(childId); + runtimeMock.state.sessionGetObserved = (sessionID) => { + if (sessionID === childId) { + ancestryAttempted.resolve(undefined); + } + }; + runtimeMock.state.permissionReplyImplementation = async () => { + await releaseReply.promise; + throw new Error("reply failed"); + }; + const terminalEvent = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + { id: "evt-ask", type: "permission.asked", properties: request }, + terminalEvent.promise, + ]; + + const requestEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "request.resolved"), + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* Effect.promise(() => ancestryAttempted.promise); + runtimeMock.state.transientErrorSessionIds.delete(childId); + yield* advanceTestClock(250); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: request.id, reply: "once" }, + ]); + + // Drain the microtask queue so the pump has consumed the terminal reply + // before the in-flight auto-reply is allowed to fail. + terminalEvent.resolve({ + id: "evt-reply", + type: "permission.replied", + properties: { sessionID: childId, requestID: request.id, reply: "once" }, + }); + yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + releaseReply.resolve(undefined); + yield* advanceTestClock(250); + + NodeAssert.equal(requestEventsFiber.pollUnsafe(), undefined); + yield* Fiber.interrupt(requestEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("routes child-session questions and replies through the parent thread", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index d0b4f0de78ce..6d94e0c09a04 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -328,6 +328,7 @@ interface OpenCodeSessionContext { readonly openCodeSessionId: string; readonly relatedSessionIds: Set; readonly resolvedRequestIds: Set; + readonly autoRepliedRequestIds: Set; readonly emittedTerminalRequestIds: Set; readonly requestRelationRetries: Map; readonly pendingPermissions: Map; @@ -1000,6 +1001,12 @@ export function makeOpenCodeAdapter( const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + // Synchronous publish for callers that must not yield between a state + // check and the enqueue, e.g. reopening an approval only if its terminal + // event has not landed yet. + const emitUnsafe = (event: ProviderRuntimeEvent) => { + Queue.offerUnsafe(runtimeEvents, event); + }; const writeNativeEvent = ( threadId: ThreadId, event: { @@ -1602,6 +1609,39 @@ export function makeOpenCodeAdapter( return false; }); + // Full access means the user already granted everything, but two upstream + // paths never consult the session ruleset we send: doom-loop detection + // (evaluated against the agent ruleset only) and subagent sessions (which + // keep only deny and external-directory rules). Answer those asks here. + // + // Reply "once", not "always": OpenCode stores "always" grants per + // directory, so on a shared external server an "always" from a full-access + // thread would silently widen what a supervised thread on the same + // directory is allowed to do. + const autoReplyFullAccess = Effect.fn("autoReplyFullAccess")(function* ( + context: OpenCodeSessionContext, + request: PermissionRequest, + ) { + // Mark before awaiting: retry and recovery fibers re-enter the ask path, + // and the matching `permission.replied` can arrive, while the SDK call + // is in flight. Marked ids skip the ask and swallow the terminal event. + context.resolvedRequestIds.add(request.id); + context.autoRepliedRequestIds.add(request.id); + const replied = yield* runOpenCodeSdk("permission.reply", () => + context.client.permission.reply({ requestID: request.id, reply: "once" }), + ).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!replied) { + // Fall back to the dialog. The id stays resolved so a recovered copy + // of this ask cannot reopen after the user answers; + // `pendingPermissions` gates re-asks while the dialog is open. + context.autoRepliedRequestIds.delete(request.id); + } + return replied; + }); + const emitPendingOpenCodeRequest = Effect.fn("emitPendingOpenCodeRequest")(function* ( context: OpenCodeSessionContext, event: OpenCodeAskedRequestEvent, @@ -1615,14 +1655,27 @@ export function makeOpenCodeAdapter( if (context.pendingPermissions.has(request.id)) { return; } + if ( + context.session.runtimeMode === "full-access" && + (yield* autoReplyFullAccess(context, request)) + ) { + return; + } + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + }); + // No yield between this check and the publish: a terminal + // `permission.replied` delivered on the pump in between would leave a + // dialog that can never close. + if (context.emittedTerminalRequestIds.has(request.id)) { + return; + } context.pendingPermissions.set(request.id, request); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId: context.activeTurnId, - requestId: request.id, - raw, - })), + emitUnsafe({ + ...base, type: "request.opened", payload: { requestType: mapPermissionToRequestType(request.permission), @@ -1671,6 +1724,9 @@ export function makeOpenCodeAdapter( return; } context.emittedTerminalRequestIds.add(requestId); + if (context.autoRepliedRequestIds.delete(requestId)) { + return; + } if (event.type === "permission.replied") { yield* emit({ ...(yield* buildEventBase({ @@ -2554,6 +2610,7 @@ export function makeOpenCodeAdapter( openCodeSessionId: started.openCodeSession.id, relatedSessionIds: new Set([started.openCodeSession.id]), resolvedRequestIds: new Set(), + autoRepliedRequestIds: new Set(), emittedTerminalRequestIds: new Set(), requestRelationRetries: new Map(), pendingPermissions: new Map(), diff --git a/docs/internals/providers.md b/docs/internals/providers.md index dabcb4a49cb8..b8770ff4375a 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -77,6 +77,12 @@ connection, while OpenCode stores MCP connections by directory. Sharing these ch without changing MCP routing would let two threads in one directory replace each other's connection. +Chat adapters send the runtime mode as a session ruleset, but upstream OpenCode evaluates +doom-loop and subagent asks against the agent ruleset only. In full access the adapter answers +those asks itself so the user never sees an approval they already granted. It replies `once` +rather than `always` because OpenCode stores `always` grants per directory, and on a shared +external server that would widen what a supervised thread in the same directory may do. + OpenCode loads its catalog through the HTTP API when an enabled provider instance starts. The provider registry keeps the snapshot in memory and persists it in the existing per-instance cache. Each `subscribeServerConfig` connection refreshes all providers, so a client reconnect reloads the