From 413e8b73b7521f9761d43c45a1622e45b550afaa Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 11 Mar 2026 16:40:30 +0900 Subject: [PATCH] Add session permission support to background agents for denying questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements question-denied session permission rules when creating child sessions via background task delegation. This prevents subagents from asking questions by passing explicit permission configuration during session creation. 🤖 GENERATED WITH ASSISTANCE OF OhMyOpenCode --- .../manager-session-permission.test.ts | 49 ++++++++++++ src/features/background-agent/manager.ts | 1 + src/features/background-agent/spawner.test.ts | 9 ++- src/features/background-agent/spawner.ts | 1 + src/features/background-agent/types.ts | 2 + .../question-denied-session-permission.ts | 9 +++ .../delegate-task/background-task.test.ts | 46 ++++++++++++ src/tools/delegate-task/background-task.ts | 2 + .../sync-session-creator.test.ts | 38 ++++++++++ .../delegate-task/sync-session-creator.ts | 2 + .../unstable-agent-permission.test.ts | 74 +++++++++++++++++++ .../delegate-task/unstable-agent-task.ts | 2 + 12 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 src/features/background-agent/manager-session-permission.test.ts create mode 100644 src/shared/question-denied-session-permission.ts create mode 100644 src/tools/delegate-task/sync-session-creator.test.ts create mode 100644 src/tools/delegate-task/unstable-agent-permission.test.ts diff --git a/src/features/background-agent/manager-session-permission.test.ts b/src/features/background-agent/manager-session-permission.test.ts new file mode 100644 index 000000000..d55bd3353 --- /dev/null +++ b/src/features/background-agent/manager-session-permission.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import { tmpdir } from "node:os" + +import type { PluginInput } from "@opencode-ai/plugin" + +import { BackgroundManager } from "./manager" + +describe("BackgroundManager session permission", () => { + test("passes explicit session permission rules to child session creation", async () => { + // given + const createCalls: Array> = [] + const client = { + session: { + get: async () => ({ data: { directory: "/parent" } }), + create: async (input: Record) => { + createCalls.push(input) + return { data: { id: "ses_child" } } + }, + promptAsync: async () => ({}), + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) + + // when + await manager.launch({ + description: "Test task", + prompt: "Do something", + agent: "explore", + parentSessionID: "ses_parent", + parentMessageID: "msg_parent", + sessionPermission: [ + { permission: "question", action: "deny", pattern: "*" }, + ], + }) + await new Promise(resolve => setTimeout(resolve, 50)) + manager.shutdown() + + // then + expect(createCalls).toHaveLength(1) + expect(createCalls[0]?.body).toEqual({ + parentID: "ses_parent", + title: "Test task (@explore subagent)", + permission: [ + { permission: "question", action: "deny", pattern: "*" }, + ], + }) + }) +}) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 1033b34b7..26e952518 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -272,6 +272,7 @@ export class BackgroundManager { body: { parentID: input.parentSessionID, title: `${input.description} (@${input.agent} subagent)`, + ...(input.sessionPermission ? { permission: input.sessionPermission } : {}), } as Record, query: { directory: parentDirectory, diff --git a/src/features/background-agent/spawner.test.ts b/src/features/background-agent/spawner.test.ts index 54f2fa007..27d26a519 100644 --- a/src/features/background-agent/spawner.test.ts +++ b/src/features/background-agent/spawner.test.ts @@ -3,7 +3,7 @@ import { describe, test, expect } from "bun:test" import { createTask, startTask } from "./spawner" describe("background-agent spawner.startTask", () => { - test("does not override parent session permission rules when creating child session", async () => { + test("applies explicit child session permission rules when creating child session", async () => { //#given const createCalls: any[] = [] const parentPermission = [ @@ -41,6 +41,9 @@ describe("background-agent spawner.startTask", () => { parentModel: task.parentModel, parentAgent: task.parentAgent, model: task.model, + sessionPermission: [ + { permission: "question", action: "deny", pattern: "*" }, + ], }, } @@ -57,6 +60,8 @@ describe("background-agent spawner.startTask", () => { //#then expect(createCalls).toHaveLength(1) - expect(createCalls[0]?.body?.permission).toBeUndefined() + expect(createCalls[0]?.body?.permission).toEqual([ + { permission: "question", action: "deny", pattern: "*" }, + ]) }) }) diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index 56817c915..c4f435720 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -61,6 +61,7 @@ export async function startTask( const createResult = await client.session.create({ body: { parentID: input.parentSessionID, + ...(input.sessionPermission ? { permission: input.sessionPermission } : {}), } as Record, query: { directory: parentDirectory, diff --git a/src/features/background-agent/types.ts b/src/features/background-agent/types.ts index 6973dd783..d1af31f43 100644 --- a/src/features/background-agent/types.ts +++ b/src/features/background-agent/types.ts @@ -1,4 +1,5 @@ import type { FallbackEntry } from "../../shared/model-requirements" +import type { SessionPermissionRule } from "../../shared/question-denied-session-permission" export type BackgroundTaskStatus = | "pending" @@ -72,6 +73,7 @@ export interface LaunchInput { skills?: string[] skillContent?: string category?: string + sessionPermission?: SessionPermissionRule[] } export interface ResumeInput { diff --git a/src/shared/question-denied-session-permission.ts b/src/shared/question-denied-session-permission.ts new file mode 100644 index 000000000..1d0681ea2 --- /dev/null +++ b/src/shared/question-denied-session-permission.ts @@ -0,0 +1,9 @@ +export type SessionPermissionRule = { + permission: string + action: "allow" | "deny" + pattern: string +} + +export const QUESTION_DENIED_SESSION_PERMISSION: SessionPermissionRule[] = [ + { permission: "question", action: "deny", pattern: "*" }, +] diff --git a/src/tools/delegate-task/background-task.test.ts b/src/tools/delegate-task/background-task.test.ts index 537819cab..f6ff49cb0 100644 --- a/src/tools/delegate-task/background-task.test.ts +++ b/src/tools/delegate-task/background-task.test.ts @@ -155,4 +155,50 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () => expectFn(metadataCalls).toHaveLength(1) expectFn(metadataCalls[0].metadata.sessionId).toBe("ses_late_123") }) + + testFn("passes question-deny session permission when launching delegate task", async () => { + //#given - delegate task background launch should deny question at session creation time + const launchCalls: any[] = [] + const manager = { + launch: async (input: any) => { + launchCalls.push(input) + return { + id: "bg_permission", + sessionID: "ses_permission_123", + description: "Permission session", + agent: "explore", + status: "running", + } + }, + getTask: () => ({ sessionID: "ses_permission_123" }), + } + + //#when + await executeBackgroundTask( + { + description: "Permission session", + prompt: "check", + run_in_background: true, + load_skills: [], + }, + { + sessionID: "ses_parent", + callID: "call_4", + metadata: async () => {}, + abort: new AbortController().signal, + }, + { manager }, + { sessionID: "ses_parent", messageID: "msg_4" }, + "explore", + undefined, + undefined, + undefined, + ) + + //#then + expectFn(launchCalls).toHaveLength(1) + expectFn(launchCalls[0].sessionPermission).toEqual([ + { permission: "question", action: "deny", pattern: "*" }, + ]) + }) }) diff --git a/src/tools/delegate-task/background-task.ts b/src/tools/delegate-task/background-task.ts index fd273fb95..1e0d2a697 100644 --- a/src/tools/delegate-task/background-task.ts +++ b/src/tools/delegate-task/background-task.ts @@ -7,6 +7,7 @@ import { storeToolMetadata } from "../../features/tool-metadata-store" import { formatDetailedError } from "./error-formatting" import { getSessionTools } from "../../shared/session-tools-store" import { SessionCategoryRegistry } from "../../shared/session-category-registry" +import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission" export async function executeBackgroundTask( args: DelegateTaskArgs, @@ -36,6 +37,7 @@ export async function executeBackgroundTask( skills: args.load_skills.length > 0 ? args.load_skills : undefined, skillContent: systemContent, category: args.category, + sessionPermission: QUESTION_DENIED_SESSION_PERMISSION, }) // OpenCode TUI's `Task` tool UI calculates toolcalls by looking up diff --git a/src/tools/delegate-task/sync-session-creator.test.ts b/src/tools/delegate-task/sync-session-creator.test.ts new file mode 100644 index 000000000..1987afcb2 --- /dev/null +++ b/src/tools/delegate-task/sync-session-creator.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" + +import { createSyncSession } from "./sync-session-creator" + +describe("createSyncSession", () => { + test("creates child session with question permission denied", async () => { + // given + const createCalls: Array> = [] + const client = { + session: { + get: async () => ({ data: { directory: "/parent" } }), + create: async (input: Record) => { + createCalls.push(input) + return { data: { id: "ses_child" } } + }, + }, + } + + // when + const result = await createSyncSession(client as never, { + parentSessionID: "ses_parent", + agentToUse: "explore", + description: "test task", + defaultDirectory: "/fallback", + }) + + // then + expect(result).toEqual({ ok: true, sessionID: "ses_child", parentDirectory: "/parent" }) + expect(createCalls).toHaveLength(1) + expect(createCalls[0]?.body).toEqual({ + parentID: "ses_parent", + title: "test task (@explore subagent)", + permission: [ + { permission: "question", action: "deny", pattern: "*" }, + ], + }) + }) +}) diff --git a/src/tools/delegate-task/sync-session-creator.ts b/src/tools/delegate-task/sync-session-creator.ts index 246b1eb44..7c463db33 100644 --- a/src/tools/delegate-task/sync-session-creator.ts +++ b/src/tools/delegate-task/sync-session-creator.ts @@ -1,4 +1,5 @@ import type { OpencodeClient } from "./types" +import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission" export async function createSyncSession( client: OpencodeClient, @@ -13,6 +14,7 @@ export async function createSyncSession( body: { parentID: input.parentSessionID, title: `${input.description} (@${input.agentToUse} subagent)`, + permission: QUESTION_DENIED_SESSION_PERMISSION, } as Record, query: { directory: parentDirectory, diff --git a/src/tools/delegate-task/unstable-agent-permission.test.ts b/src/tools/delegate-task/unstable-agent-permission.test.ts new file mode 100644 index 000000000..190eddcf2 --- /dev/null +++ b/src/tools/delegate-task/unstable-agent-permission.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test" + +import { executeUnstableAgentTask } from "./unstable-agent-task" + +describe("executeUnstableAgentTask session permission", () => { + test("passes question-deny session permission into background launch", async () => { + // given + const launchCalls: Array> = [] + const mockManager = { + launch: async (input: Record) => { + launchCalls.push(input) + return { + id: "bg_unstable_permission", + sessionID: "ses_unstable_permission", + description: "test task", + agent: "sisyphus-junior", + status: "running", + } + }, + getTask: () => ({ + id: "bg_unstable_permission", + sessionID: "ses_unstable_permission", + status: "interrupt", + description: "test task", + agent: "sisyphus-junior", + error: "stop after launch", + }), + } + const toolContext = { + sessionID: "parent-session", + messageID: "msg_parent", + agent: "sisyphus", + metadata: () => {}, + abort: new AbortController().signal, + } satisfies Parameters[1] + const executorContext = { + manager: mockManager, + client: { + session: { + status: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + }, + } as unknown as Parameters[2] + const parentContext = { + sessionID: "parent-session", + messageID: "msg_parent", + } satisfies Parameters[3] + + // when + await executeUnstableAgentTask( + { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + }, + toolContext, + executorContext, + parentContext, + "sisyphus-junior", + undefined, + undefined, + "test-model", + ) + + // then + expect(launchCalls).toHaveLength(1) + expect(launchCalls[0]?.sessionPermission).toEqual([ + { permission: "question", action: "deny", pattern: "*" }, + ]) + }) +}) diff --git a/src/tools/delegate-task/unstable-agent-task.ts b/src/tools/delegate-task/unstable-agent-task.ts index c455d7311..5b92955bd 100644 --- a/src/tools/delegate-task/unstable-agent-task.ts +++ b/src/tools/delegate-task/unstable-agent-task.ts @@ -7,6 +7,7 @@ import { formatDuration } from "./time-formatter" import { formatDetailedError } from "./error-formatting" import { getSessionTools } from "../../shared/session-tools-store" import { normalizeSDKResponse } from "../../shared" +import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission" export async function executeUnstableAgentTask( args: DelegateTaskArgs, @@ -35,6 +36,7 @@ export async function executeUnstableAgentTask( skills: args.load_skills.length > 0 ? args.load_skills : undefined, skillContent: systemContent, category: args.category, + sessionPermission: QUESTION_DENIED_SESSION_PERMISSION, }) const timing = getTimingConfig()