diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index bc80db9d09cf..6ab919652465 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -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, @@ -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 = []; + const updatedPermissions: Array = []; + 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", @@ -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" }, diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index d872b723df79..e6b0c3114d65 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -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 { @@ -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, @@ -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", @@ -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; @@ -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, @@ -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); @@ -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) => @@ -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) =>