Skip to content
Closed
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
146 changes: 145 additions & 1 deletion packages/provider-opencode/src/server/adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { assert, describe, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2";
import type { OpencodeClient, PermissionRuleset, ToolPart } from "@opencode-ai/sdk/v2";
import {
CheckpointId,
NodeId,
Expand Down Expand Up @@ -236,6 +236,70 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct
};
});

const makePermissionRuleHarness = Effect.fn("makePermissionRuleHarness")(function* (
suffix: string,
nativeSession: { readonly id: string; readonly parentID?: string },
initialMode: ProviderAdapter.ProviderAdapterV2RuntimePolicy["runtimeMode"] = "approval-required",
) {
const nativeEvents = asyncEventStream();
const createdPermissions: Array<unknown> = [];
const updatedPermissions: Array<unknown> = [];
const instanceId = ProviderInstanceId.make(`opencode-${suffix}`);
const threadId = ThreadId.make(`thread-${suffix}`);
const modelSelection = {
instanceId,
model: "anthropic/claude-sonnet",
options: [],
};
const nativeSessionData = { ...nativeSession, time: { created: 1, updated: 1 } };
const adapter = yield* makeOpenCodeAdapterV2({
instanceId,
settings: OPEN_CODE_TEST_SETTINGS,
environment: {},
}).pipe(
Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, {
connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }),
createOpenCodeSdkClient: () => ({
event: { subscribe: async () => ({ stream: nativeEvents.stream }) },
session: {
create: async (input: { permission?: unknown }) => {
createdPermissions.push(input.permission);
return { data: nativeSessionData };
},
update: async (input: { permission?: unknown }) => {
updatedPermissions.push(input.permission);
return { data: true };
},
get: async () => ({ data: nativeSessionData }),
},
}),
} as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]),
Effect.provideService(ProviderHost.ProviderHost, {
paths: { cwd: "/workspace" },
resolveAttachmentPath: () => null,
} as unknown as ProviderHost.ProviderHost["Service"]),
);
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make(`session-${suffix}`),
modelSelection,
runtimePolicy: runtimePolicy(initialMode, { cwd: "/workspace" }),
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection,
runtimePolicy: runtimePolicy(initialMode, { cwd: "/workspace" }),
});
return {
createdPermissions,
updatedPermissions,
modelSelection,
providerThread,
runtime,
threadId,
};
});

describe("OpenCodeAdapterV2", () => {
it.effect.each(["completed", "failed", "unavailable"] as const)(
"normalizes OpenCode step usage for %s turns",
Expand Down Expand Up @@ -2263,6 +2327,86 @@ describe("OpenCodeAdapterV2", () => {
});
});

it.effect("reapplies the runtime mode permission rules when a session is resumed", () =>
Effect.gen(function* () {
const harness = yield* makePermissionRuleHarness("resume-policy", { id: "root" });
assert.equal(
permissionAction(harness.createdPermissions[0] as PermissionRuleset, "bash"),
"ask",
);

yield* harness.runtime.resumeThread({
providerThread: harness.providerThread,
threadId: harness.threadId,
modelSelection: harness.modelSelection,
runtimePolicy: runtimePolicy("full-access", { cwd: "/workspace" }),
});

assert.equal(harness.updatedPermissions.length, 1);
assert.equal(
permissionAction(harness.updatedPermissions[0] as PermissionRuleset, "bash"),
"allow",
);
}).pipe(
Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)),
Effect.scoped,
),
);

it.effect("reapplies the restricted rules when a full-access session is resumed", () =>
Effect.gen(function* () {
const harness = yield* makePermissionRuleHarness(
"resume-restricted-policy",
{ id: "root" },
"full-access",
);
assert.equal(
permissionAction(harness.createdPermissions[0] as PermissionRuleset, "bash"),
"allow",
);

yield* harness.runtime.resumeThread({
providerThread: harness.providerThread,
threadId: harness.threadId,
modelSelection: harness.modelSelection,
runtimePolicy: runtimePolicy("approval-required", { cwd: "/workspace" }),
});

assert.equal(harness.updatedPermissions.length, 1);
assert.equal(
permissionAction(harness.updatedPermissions[0] as PermissionRuleset, "bash"),
"ask",
);
}).pipe(
Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)),
Effect.scoped,
),
);

it.effect("keeps the rules of a resumed native child session", () =>
Effect.gen(function* () {
// A server restart drops the in-memory subagent marker, so the native
// parent id is the only signal that this session carries the child
// agent's own restrictions.
const harness = yield* makePermissionRuleHarness("resume-child-policy", {
id: "child",
parentID: "root",
});

yield* harness.runtime.resumeThread({
providerThread: harness.providerThread,
threadId: harness.threadId,
modelSelection: harness.modelSelection,
runtimePolicy: runtimePolicy("full-access", { cwd: "/workspace" }),
});

assert.deepEqual(harness.updatedPermissions, []);
}).pipe(
Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)),
Effect.scoped,
),
);

it("preserves OpenCode's recursion guard on task-created child sessions", () => {
const childRules = openCodeChildPermissionRules(runtimePolicy("full-access"), [
{ permission: "task", pattern: "*", action: "deny" },
Expand Down
70 changes: 64 additions & 6 deletions packages/provider-opencode/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,12 @@ interface OpenCodeThreadState {
parentSubagent: OpenCodeSubagentContext | null;
nextChildTurnOrdinal: number;
nextAdmissionGeneration: number;
/**
* The permission rules the native session currently carries. OpenCode keeps
* them on the session, so a runtime mode change only lands when they are
* written back.
*/
appliedPermissionRules: PermissionRuleset | null;
}

interface OpenCodeRequestOwner {
Expand Down Expand Up @@ -693,7 +699,23 @@ function permissionRuleEquals(
);
}

/** Children inherit only deny/external-directory rules; restore parent policy while retaining agent restrictions. */
function permissionRulesEqual(left: PermissionRuleset | null, right: PermissionRuleset): boolean {
return (
left !== null &&
left.length === right.length &&
left.every((rule, index) => {
const other = right[index];
return other !== undefined && permissionRuleEquals(rule, other);
})
);
}

/**
* OpenCode task sessions inherit only the parent's deny/external-directory
* rules and then add agent-specific restrictions such as disabling nested
* tasks. Install the complete parent policy while retaining only rules that
* were added specifically for the selected child agent.
*/
export function openCodeChildPermissionRules(
runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
nativeChildRules: PermissionRuleset,
Expand Down Expand Up @@ -1391,6 +1413,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function
parentSubagent: context,
nextChildTurnOrdinal: 1,
nextAdmissionGeneration: 1,
appliedPermissionRules: childPermission,
});
yield* emitProviderEvent({
type: "app_thread.created",
Expand Down Expand Up @@ -2785,6 +2808,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function
parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null,
nextChildTurnOrdinal: 1,
nextAdmissionGeneration: 1,
appliedPermissionRules: null,
};
threads.set(nativeSession.id, state);
return state;
Expand Down Expand Up @@ -2967,6 +2991,33 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function
);
});

const applyPermissionRules = Effect.fn("OpencodeAdapter.applyPermissionRules")(function* (
state: OpenCodeThreadState,
runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy | undefined,
nativeParentId: string | undefined,
) {
// A native subagent session carries the parent's rules plus the
// restrictions of its own agent, so the parent policy must not
// overwrite them. The in-memory marker is gone after a server
// restart, so the native parent id is the durable signal.
if (
runtimePolicy === undefined ||
state.parentSubagent !== null ||
nativeParentId !== undefined
) {
return;
}
const rules = openCodePermissionRules(runtimePolicy);
if (permissionRulesEqual(state.appliedPermissionRules, rules)) return;
yield* sdkCall("session.update", { sessionID: state.nativeSessionId }, () =>
client.session.update({
sessionID: state.nativeSessionId,
permission: rules,
}),
);
state.appliedPermissionRules = rules;
});

const runtimeSession: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
instanceId: options.instanceId,
driver: OPENCODE_PROVIDER,
Expand All @@ -2979,15 +3030,20 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function
if (threadInput.existingProviderThread?.nativeThreadRef != null) {
return yield* runtimeSession.resumeThread({
providerThread: threadInput.existingProviderThread,
threadId: threadInput.threadId,
modelSelection: threadInput.modelSelection,
runtimePolicy: threadInput.runtimePolicy,
});
}
// OpenCode generates a title only when session.create leaves it unset.
// No title: OpenCode generates one from the first prompt only when
// session.create leaves it unset (SessionPrompt.ensureTitle).
const permissionRules = openCodePermissionRules(threadInput.runtimePolicy);
const response = yield* sdkCall(
"session.create",
{ permission: openCodePermissionRules(threadInput.runtimePolicy) },
{ permission: permissionRules },
() =>
client.session.create({
permission: openCodePermissionRules(threadInput.runtimePolicy),
permission: permissionRules,
}),
);
const nativeSession = unwrapData("session.create", response);
Expand All @@ -3012,7 +3068,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function
status: created.status,
updatedAt: created.updatedAt,
};
registerThread(nativeSession, providerThread, null);
const state = registerThread(nativeSession, providerThread, null);
state.appliedPermissionRules = permissionRules;
return providerThread;
},
(effect, threadInput) =>
Expand Down Expand Up @@ -3041,7 +3098,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function
status: "idle" as const,
updatedAt: dateTimeFromEpoch(nativeSession.time.updated, resumedAt),
};
registerThread(nativeSession, providerThread, null);
const state = registerThread(nativeSession, providerThread, null);
yield* applyPermissionRules(state, threadInput.runtimePolicy, nativeSession.parentID);
return providerThread;
},
(effect, threadInput) =>
Expand Down
Loading