Add session permission support to background agents for denying questions

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
This commit is contained in:
YeonGyu-Kim
2026-03-11 16:40:30 +09:00
parent 24f4e14f07
commit 413e8b73b7
12 changed files with 233 additions and 2 deletions

View File

@@ -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<Record<string, unknown>> = []
const client = {
session: {
get: async () => ({ data: { directory: "/parent" } }),
create: async (input: Record<string, unknown>) => {
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: "*" },
],
})
})
})

View File

@@ -272,6 +272,7 @@ export class BackgroundManager {
body: {
parentID: input.parentSessionID,
title: `${input.description} (@${input.agent} subagent)`,
...(input.sessionPermission ? { permission: input.sessionPermission } : {}),
} as Record<string, unknown>,
query: {
directory: parentDirectory,

View File

@@ -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: "*" },
])
})
})

View File

@@ -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<string, unknown>,
query: {
directory: parentDirectory,

View File

@@ -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 {

View File

@@ -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: "*" },
]

View File

@@ -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: "*" },
])
})
})

View File

@@ -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

View File

@@ -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<Record<string, unknown>> = []
const client = {
session: {
get: async () => ({ data: { directory: "/parent" } }),
create: async (input: Record<string, unknown>) => {
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: "*" },
],
})
})
})

View File

@@ -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<string, unknown>,
query: {
directory: parentDirectory,

View File

@@ -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<Record<string, unknown>> = []
const mockManager = {
launch: async (input: Record<string, unknown>) => {
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<typeof executeUnstableAgentTask>[1]
const executorContext = {
manager: mockManager,
client: {
session: {
status: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
},
} as unknown as Parameters<typeof executeUnstableAgentTask>[2]
const parentContext = {
sessionID: "parent-session",
messageID: "msg_parent",
} satisfies Parameters<typeof executeUnstableAgentTask>[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: "*" },
])
})
})

View File

@@ -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()