diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index de0459bafac1d..759545fff0d19 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -296,6 +296,112 @@ describe("OpenCodeAdapterV2", () => { ), ); + it.effect("applies a changed runtime policy to existing subagent sessions", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const push = (event: unknown) => Effect.promise(() => nativeEvents.push(event)); + const updates: Array<{ + sessionID: string; + permission: ReturnType; + }> = []; + const fullAccess = runtimePolicy("full-access", { cwd: "/workspace" }); + const supervised = runtimePolicy("approval-required", { cwd: "/workspace" }); + const root = "ses_root"; + const child = "ses_child"; + const taskDeny = { permission: "task", pattern: "*", action: "deny" as const }; + const nativePermissions = new Map([ + [root, openCodePermissionRules(fullAccess)], + [child, [taskDeny]], + ]); + let failNextChildUpdate = false; + const harness = yield* makeOpenCodeRuntimeHarness("child-policy-change", root, { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: root, time: { created: 1, updated: 1 } } }), + get: async ({ sessionID }: { sessionID: string }) => ({ + data: { + id: sessionID, + ...(sessionID === child ? { parentID: root } : {}), + permission: nativePermissions.get(sessionID), + time: { created: 1, updated: 1 }, + }, + }), + update: async (input: (typeof updates)[number]) => { + if (input.sessionID === child && failNextChildUpdate) { + failNextChildUpdate = false; + throw new Error("child update failed"); + } + updates.push(input); + nativePermissions.set(input.sessionID, input.permission); + return { data: { id: input.sessionID, time: { created: 1, updated: 2 } } }; + }, + promptAsync: async () => ({ data: true }), + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }); + + yield* harness.startTurn("hello", harness.providerThread, fullAccess); + yield* push({ + type: "message.part.updated", + properties: { + sessionID: root, + part: { + id: "prt_task", + sessionID: root, + messageID: "msg_root_assistant", + type: "tool", + tool: "task", + callID: "call_task", + state: { + status: "running", + input: { description: "Explore", prompt: "look around", subagent_type: "general" }, + title: "Explore", + metadata: { parentSessionId: root, sessionId: child }, + time: { start: 3 }, + }, + }, + }, + }); + const childUpdates = () => updates.filter((update) => update.sessionID === child); + assert.equal(permissionAction(childUpdates().at(-1)!.permission, "bash"), "allow"); + + yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + runtimePolicy: supervised, + }); + const childRules = childUpdates().at(-1)!.permission; + assert.equal(childUpdates().length, 2); + assert.equal(permissionAction(childRules, "bash"), "ask"); + assert.equal(permissionAction(childRules, "task"), "deny"); + assert.deepStrictEqual(childRules, openCodeChildPermissionRules(supervised, [taskDeny])); + + yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + runtimePolicy: fullAccess, + }); + assert.equal(permissionAction(nativePermissions.get(child)!, "bash"), "allow"); + + failNextChildUpdate = true; + const failed = yield* Effect.exit( + harness.runtime.resumeThread({ + providerThread: harness.providerThread, + runtimePolicy: supervised, + }), + ); + assert.isTrue(Exit.isFailure(failed)); + yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + runtimePolicy: supervised, + }); + assert.equal(permissionAction(nativePermissions.get(child)!, "bash"), "ask"); + assert.equal(permissionAction(nativePermissions.get(child)!, "task"), "deny"); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect.each(["completed", "failed", "unavailable"] as const)( "normalizes OpenCode step usage for %s turns", (ending) => @@ -2428,6 +2534,17 @@ describe("OpenCodeAdapterV2", () => { ]); assert.equal(permissionAction(childApprovalRules, "bash"), "ask"); assert.equal(permissionAction(childApprovalRules, "task"), "deny"); + + const fullAccessChildRules = openCodeChildPermissionRules(runtimePolicy("full-access"), [ + { permission: "task", pattern: "*", action: "deny" }, + ]); + const reappliedRules = openCodeChildPermissionRules( + approvalRequiredPolicy, + fullAccessChildRules, + openCodePermissionRules(runtimePolicy("full-access")), + ); + assert.equal(permissionAction(reappliedRules, "bash"), "ask"); + assert.equal(permissionAction(reappliedRules, "task"), "deny"); }); it("uses the next native user message as the exclusive fork and revert boundary", () => { diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 31ee2e632f27f..bad3e8cd7b89a 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -378,6 +378,8 @@ interface OpenCodeThreadState { nextChildTurnOrdinal: number; nextAdmissionGeneration: number; permission: PermissionRuleset; + /** Parent rules last written into this child's `permission`; empty for root sessions. */ + inheritedPermission: PermissionRuleset; } interface OpenCodeRequestOwner { @@ -691,16 +693,23 @@ function permissionRuleEquals( ); } -/** Children inherit only deny/external-directory rules; restore parent policy while retaining agent restrictions. */ +/** + * Children inherit only deny/external-directory rules; restore parent policy while retaining agent restrictions. + * `previousParentRules` are the parent rules already written into `childRules`. + */ export function openCodeChildPermissionRules( runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, - nativeChildRules: PermissionRuleset, + childRules: PermissionRuleset, + previousParentRules: PermissionRuleset = [], ): PermissionRuleset { const parentRules = openCodePermissionRules(runtimePolicy); - const inheritedRules = parentRules.filter( - (rule) => rule.permission === "external_directory" || rule.action === "deny", - ); - const childSpecificRules = nativeChildRules.filter( + const inheritedRules = [ + ...parentRules.filter( + (rule) => rule.permission === "external_directory" || rule.action === "deny", + ), + ...previousParentRules, + ]; + const childSpecificRules = childRules.filter( (childRule) => !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule)), ); @@ -1395,6 +1404,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function nextChildTurnOrdinal: 1, nextAdmissionGeneration: 1, permission: childPermission, + inheritedPermission: openCodePermissionRules(turn.runtimePolicy), }); yield* emitProviderEvent({ type: "app_thread.created", @@ -2777,6 +2787,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function existing.permission = nativeSession.permission; return existing; } + const parentSubagent = subagentsByChildSessionId.get(nativeSession.id) ?? null; const state: OpenCodeThreadState = { nativeSessionId: nativeSession.id, providerThread, @@ -2786,30 +2797,44 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function runtimeRequests: new Map(), messageRoles: new Map(), userMessageIds: [], - parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null, + parentSubagent, nextChildTurnOrdinal: 1, nextAdmissionGeneration: 1, permission: nativeSession.permission ?? [], + inheritedPermission: parentSubagent?.parentState.permission ?? [], }; threads.set(nativeSession.id, state); return state; }; + // OpenCode copies parent rules into a child only at creation, so children are updated here too. const applyPermissionRules = Effect.fnUntraced(function* ( state: OpenCodeThreadState, policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, ) { - const permission = openCodePermissionRules(policy); - if ( - permission.length === state.permission.length && - permission.every((rule, index) => permissionRuleEquals(rule, state.permission[index]!)) - ) { - return; + const parentRules = openCodePermissionRules(policy); + const pending = [state]; + for (let target = pending.pop(); target !== undefined; target = pending.pop()) { + const previous = target.permission; + const permission = + target.parentSubagent === null + ? parentRules + : openCodeChildPermissionRules(policy, previous, target.inheritedPermission); + if ( + permission.length !== previous.length || + !permission.every((rule, index) => permissionRuleEquals(rule, previous[index]!)) + ) { + const sessionID = target.nativeSessionId; + yield* sdkCall("session.update", { sessionID, permission }, () => + client.session.update({ sessionID, permission }), + ); + target.permission = permission; + } + if (target.parentSubagent !== null) target.inheritedPermission = parentRules; + for (const child of threads.values()) { + if (child.parentSubagent?.parentState === target) pending.push(child); + } } - yield* sdkCall("session.update", { sessionID: state.nativeSessionId, permission }, () => - client.session.update({ sessionID: state.nativeSessionId, permission }), - ); - state.permission = permission; }); const resolvePromptParts = Effect.fnUntraced(function* (