From 43de869926833363e252bf4ddb83994507efaafe Mon Sep 17 00:00:00 2001 From: Fluffy-Bunny-23 <193831219+Fluffy-Bunny-23@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:45:01 -0700 Subject: [PATCH 1/4] fix(opencode): runtime mode changes reach an existing OpenCode session Switching a thread from Supervised to Full access did not stop OpenCode from asking for approval. OpenCode keeps permission rules on the native session, and the adapter only wrote them at session.create, so every later turn resumed the session with the rules it was born with. The adapter now tracks the rules a native session carries and writes them back on resume when the runtime mode changed. Native subagent sessions keep their own agent-specific rules. Co-Authored-By: opencode/minimax-m3.1-flash-free --- .../src/server/adapter.test.ts | 70 ++++++++++++++++++- .../provider-opencode/src/server/adapter.ts | 53 ++++++++++++-- 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 72348b98aa77..26822dba3e2c 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, @@ -2756,6 +2756,74 @@ describe("OpenCodeAdapterV2", () => { }); }); + it.effect("reapplies the runtime mode permission rules when a session is resumed", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const createdPermissions: Array = []; + const updatedPermissions: Array = []; + const instanceId = ProviderInstanceId.make("opencode-resume-policy"); + const threadId = ThreadId.make("thread-opencode-resume-policy"); + const modelSelection = { + instanceId, + model: "anthropic/claude-sonnet", + options: [], + }; + 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: { id: "root", time: { created: 1, updated: 1 } } }; + }, + update: async (input: { permission?: unknown }) => { + updatedPermissions.push(input.permission); + return { data: true }; + }, + get: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + }, + }), + } 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-opencode-resume-policy"), + modelSelection, + runtimePolicy: runtimePolicy("approval-required", { cwd: "/workspace" }), + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy: runtimePolicy("approval-required", { cwd: "/workspace" }), + }); + assert.equal(permissionAction(createdPermissions[0] as PermissionRuleset, "bash"), "ask"); + + yield* runtime.resumeThread({ + providerThread, + threadId, + modelSelection, + runtimePolicy: runtimePolicy("full-access", { cwd: "/workspace" }), + }); + + assert.equal(updatedPermissions.length, 1); + assert.equal(permissionAction(updatedPermissions[0] as PermissionRuleset, "bash"), "allow"); + }).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 4a73195d2dc7..fb6532be4048 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -388,6 +388,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 { @@ -706,6 +712,17 @@ function permissionRuleEquals( ); } +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 @@ -1417,6 +1434,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function parentSubagent: context, nextChildTurnOrdinal: 1, nextAdmissionGeneration: 1, + appliedPermissionRules: childPermission, }); yield* emitProviderEvent({ type: "app_thread.created", @@ -2806,6 +2824,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; @@ -2991,6 +3010,26 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ); }); + const applyPermissionRules = ( + state: OpenCodeThreadState, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy | undefined, + ) => + Effect.gen(function* () { + // A native subagent session carries the parent's rules plus the + // restrictions of its own agent, so the parent policy must not + // overwrite them. + if (runtimePolicy === undefined || state.parentSubagent !== null) 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, @@ -3003,16 +3042,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, }); } // 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); @@ -3037,7 +3080,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; }).pipe( Effect.mapError( @@ -3063,7 +3107,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); return providerThread; }).pipe( Effect.mapError( From 4582d01d14fa464f803cb5ebcd65091b9fd52b98 Mon Sep 17 00:00:00 2001 From: Fluffy-Bunny-23 <193831219+Fluffy-Bunny-23@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:13:05 -0700 Subject: [PATCH 2/4] fix(opencode): keep a native child session's own rules on resume CodeRabbit review of the previous commit: the in-memory subagent marker is gone after a server restart, so a resumed native child session looked like a root session and received the root runtime-policy rules, replacing the child agent's own restrictions. The native session's parent id is the durable signal, so a resumed session with a parent keeps the rules it carries. Co-Authored-By: opencode/minimax-m3.1-flash-free --- .../src/server/adapter.test.ts | 151 ++++++++++++------ .../provider-opencode/src/server/adapter.ts | 14 +- 2 files changed, 109 insertions(+), 56 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 26822dba3e2c..296cd83837e4 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -240,6 +240,69 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }; }); +const makePermissionRuleHarness = Effect.fn("makePermissionRuleHarness")(function* ( + suffix: string, + nativeSession: { readonly id: string; readonly parentID?: string }, +) { + 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("approval-required", { cwd: "/workspace" }), + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy: runtimePolicy("approval-required", { cwd: "/workspace" }), + }); + return { + createdPermissions, + updatedPermissions, + modelSelection, + providerThread, + runtime, + threadId, + }; +}); + describe("OpenCodeAdapterV2", () => { it.effect.each(["completed", "failed", "unresolved", "unavailable", "reconnect"] as const)( "normalizes OpenCode step usage for %s turns", @@ -2758,66 +2821,48 @@ describe("OpenCodeAdapterV2", () => { it.effect("reapplies the runtime mode permission rules when a session is resumed", () => Effect.gen(function* () { - const nativeEvents = asyncEventStream(); - const createdPermissions: Array = []; - const updatedPermissions: Array = []; - const instanceId = ProviderInstanceId.make("opencode-resume-policy"); - const threadId = ThreadId.make("thread-opencode-resume-policy"); - const modelSelection = { - instanceId, - model: "anthropic/claude-sonnet", - options: [], - }; - 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: { id: "root", time: { created: 1, updated: 1 } } }; - }, - update: async (input: { permission?: unknown }) => { - updatedPermissions.push(input.permission); - return { data: true }; - }, - get: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), - }, - }), - } as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]), - Effect.provideService(ProviderHost.ProviderHost, { - paths: { cwd: "/workspace" }, - resolveAttachmentPath: () => null, - } as unknown as ProviderHost.ProviderHost["Service"]), + const harness = yield* makePermissionRuleHarness("resume-policy", { id: "root" }); + assert.equal( + permissionAction(harness.createdPermissions[0] as PermissionRuleset, "bash"), + "ask", ); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("session-opencode-resume-policy"), - modelSelection, - runtimePolicy: runtimePolicy("approval-required", { cwd: "/workspace" }), + + yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + threadId: harness.threadId, + modelSelection: harness.modelSelection, + runtimePolicy: runtimePolicy("full-access", { cwd: "/workspace" }), }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy: runtimePolicy("approval-required", { 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("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", }); - assert.equal(permissionAction(createdPermissions[0] as PermissionRuleset, "bash"), "ask"); - yield* runtime.resumeThread({ - providerThread, - threadId, - modelSelection, + yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + threadId: harness.threadId, + modelSelection: harness.modelSelection, runtimePolicy: runtimePolicy("full-access", { cwd: "/workspace" }), }); - assert.equal(updatedPermissions.length, 1); - assert.equal(permissionAction(updatedPermissions[0] as PermissionRuleset, "bash"), "allow"); + assert.deepEqual(harness.updatedPermissions, []); }).pipe( Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), Effect.scoped, diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index fb6532be4048..c95c119fe0e5 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -3013,12 +3013,20 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const applyPermissionRules = ( state: OpenCodeThreadState, runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy | undefined, + nativeParentId: string | undefined, ) => Effect.gen(function* () { // A native subagent session carries the parent's rules plus the // restrictions of its own agent, so the parent policy must not - // overwrite them. - if (runtimePolicy === undefined || state.parentSubagent !== null) return; + // 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 }, () => @@ -3108,7 +3116,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function updatedAt: dateTimeFromEpoch(nativeSession.time.updated, resumedAt), }; const state = registerThread(nativeSession, providerThread, null); - yield* applyPermissionRules(state, threadInput.runtimePolicy); + yield* applyPermissionRules(state, threadInput.runtimePolicy, nativeSession.parentID); return providerThread; }).pipe( Effect.mapError( From ccb346eb3ec490a4e0052163982980904c8738d2 Mon Sep 17 00:00:00 2001 From: Fluffy-Bunny-23 <193831219+Fluffy-Bunny-23@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:31:34 -0700 Subject: [PATCH 3/4] test(opencode): cover resumed permission tightening Implemented With: Codex via gpt-6-luna on OpenAI through T3 Code --- .../src/server/adapter.test.ts | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 8c70c140e24e..6ab919652465 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -239,6 +239,7 @@ 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 = []; @@ -282,12 +283,12 @@ const makePermissionRuleHarness = Effect.fn("makePermissionRuleHarness")(functio threadId, providerSessionId: ProviderSessionId.make(`session-${suffix}`), modelSelection, - runtimePolicy: runtimePolicy("approval-required", { cwd: "/workspace" }), + runtimePolicy: runtimePolicy(initialMode, { cwd: "/workspace" }), }); const providerThread = yield* runtime.ensureThread({ threadId, modelSelection, - runtimePolicy: runtimePolicy("approval-required", { cwd: "/workspace" }), + runtimePolicy: runtimePolicy(initialMode, { cwd: "/workspace" }), }); return { createdPermissions, @@ -2352,6 +2353,36 @@ describe("OpenCodeAdapterV2", () => { ), ); + 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 From 23d442e34737394fe83dee608d0869cab542c789 Mon Sep 17 00:00:00 2001 From: Fluffy-Bunny-23 <193831219+Fluffy-Bunny-23@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:22:25 -0700 Subject: [PATCH 4/4] fix(opencode): trace permission rule updates Implemented With: Codex via gpt-6-luna on OpenAI through T3 Code --- .../provider-opencode/src/server/adapter.ts | 47 +++++++++---------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 266db83dcc78..e6b0c3114d65 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -2991,33 +2991,32 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ); }); - const applyPermissionRules = ( + const applyPermissionRules = Effect.fn("OpencodeAdapter.applyPermissionRules")(function* ( state: OpenCodeThreadState, runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy | undefined, nativeParentId: string | undefined, - ) => - Effect.gen(function* () { - // 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; - }); + ) { + // 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,