Skip to content
Open
207 changes: 207 additions & 0 deletions apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
Original file line number Diff line number Diff 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 Down Expand 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 Down Expand 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 Down Expand 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
74 changes: 67 additions & 7 deletions apps/server/src/provider/Layers/OpenCodeAdapter.ts
Original file line number Diff line number Diff 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 Down Expand 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 Down Expand Up @@ -1602,6 +1609,42 @@ 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) {
return "replied" as const;
}
// 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 context.emittedTerminalRequestIds.has(request.id)
? ("already-terminal" as const)
: ("fallback" as const);
});

const emitPendingOpenCodeRequest = Effect.fn("emitPendingOpenCodeRequest")(function* (
context: OpenCodeSessionContext,
event: OpenCodeAskedRequestEvent,
Expand All @@ -1615,14 +1658,27 @@ export function makeOpenCodeAdapter(
if (context.pendingPermissions.has(request.id)) {
return;
}
if (context.session.runtimeMode === "full-access") {
const outcome = yield* autoReplyFullAccess(context, request);
if (outcome !== "fallback") {
return;
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
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 Down Expand Up @@ -1671,6 +1727,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 Down Expand Up @@ -2554,6 +2613,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
3 changes: 2 additions & 1 deletion docs/internals/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ attachment to the provider adapter. Each adapter decides what its provider inges
Claude receives the attachment directory as an allowed additional directory. Codex keeps its
configured sandbox policy, so access depends on that policy and the selected runtime mode. OpenCode
allows all paths in full-access mode and requests approval for directories outside the workspace in
restricted modes. Cursor and Grok use their own provider permission rules.
restricted modes. Upstream OpenCode evaluates doom-loop and subagent asks against the agent ruleset
only, ignoring the session ruleset T3 sends, so the adapter answers those asks itself in full access. Cursor and Grok use their own provider permission rules.

The server does not copy attachments into a project or bypass provider approval rules. If an agent
cannot read an attachment, the user must approve the access or select a runtime mode that permits it.
Expand Down
Loading