Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,6 +89,7 @@ const runtimeMock = {
subscribedEvents: [] as Array<unknown | Promise<unknown>>,
eventSubscribeObserved: null as (() => void) | null,
permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>,
permissionReplyImplementation: null as (() => Promise<void>) | null,
questionReplyCalls: [] as Array<{
requestID: string;
answers: ReadonlyArray<ReadonlyArray<string>>;
Expand DownExpand Up@@ -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;
Expand DownExpand Up@@ -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: {
Expand DownExpand Up@@ -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<void>();
const releaseReply = promiseWithResolvers<void>();
// 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<unknown>();
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<void>((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;
Expand Down
71 changes: 64 additions & 7 deletions apps/server/src/provider/Layers/OpenCodeAdapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -328,6 +328,7 @@ interface OpenCodeSessionContext {
readonly openCodeSessionId: string;
readonly relatedSessionIds: Set<string>;
readonly resolvedRequestIds: Set<string>;
readonly autoRepliedRequestIds: Set<string>;
readonly emittedTerminalRequestIds: Set<string>;
readonly requestRelationRetries: Map<string, OpenCodeRequestRelationRetry>;
readonly pendingPermissions: Map<string, PermissionRequest>;
Expand DownExpand Up@@ -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: {
Expand DownExpand Up@@ -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,
Expand All@@ -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;
}
Comment thread
cursor[bot] marked this conversation as resolved.
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),
Expand DownExpand Up@@ -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({
Expand DownExpand Up@@ -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(),
Expand Down
6 changes: 6 additions & 0 deletions docs/internals/providers.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Loading