From 1324fee30f2ac9ddaa09b6a112adeae653130ccf Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 18:41:26 +0900 Subject: [PATCH] feat(cli/run, background-agent): manage session permissions for CLI and background tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deny question prompts in CLI run mode since there's no TUI to answer them - Inherit parent session permission rules in background task sessions - Force deny questions while preserving other parent permission settings - Add test coverage for permission inheritance behavior 🤖 Generated with assistance of OhMyOpenCode --- src/cli/run/session-resolver.test.ts | 34 +++++++--- src/cli/run/session-resolver.ts | 13 ++-- src/features/background-agent/manager.test.ts | 65 ++++++++++++++++--- src/features/background-agent/manager.ts | 10 ++- src/features/background-agent/spawner.test.ts | 65 +++++++++++++++++++ src/features/background-agent/spawner.ts | 10 ++- 6 files changed, 169 insertions(+), 28 deletions(-) create mode 100644 src/features/background-agent/spawner.test.ts diff --git a/src/cli/run/session-resolver.test.ts b/src/cli/run/session-resolver.test.ts index ca7d9f65..a9775bb4 100644 --- a/src/cli/run/session-resolver.test.ts +++ b/src/cli/run/session-resolver.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test" -import { resolveSession } from "./session-resolver" -import type { OpencodeClient } from "./types" +/// + +import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { resolveSession } from "./session-resolver"; +import type { OpencodeClient } from "./types"; const createMockClient = (overrides: { getResult?: { error?: unknown; data?: { id: string } } @@ -58,7 +60,9 @@ describe("resolveSession", () => { const result = resolveSession({ client: mockClient, sessionId }) // then - await expect(result).rejects.toThrow(`Session not found: ${sessionId}`) + await Promise.resolve( + expect(result).rejects.toThrow(`Session not found: ${sessionId}`) + ) expect(mockClient.session.get).toHaveBeenCalledWith({ path: { id: sessionId }, }) @@ -77,7 +81,12 @@ describe("resolveSession", () => { // then expect(result).toBe("new-session-id") expect(mockClient.session.create).toHaveBeenCalledWith({ - body: { title: "oh-my-opencode run" }, + body: { + title: "oh-my-opencode run", + permission: [ + { permission: "question", action: "deny", pattern: "*" }, + ], + }, }) expect(mockClient.session.get).not.toHaveBeenCalled() }) @@ -98,7 +107,12 @@ describe("resolveSession", () => { expect(result).toBe("retried-session-id") expect(mockClient.session.create).toHaveBeenCalledTimes(2) expect(mockClient.session.create).toHaveBeenCalledWith({ - body: { title: "oh-my-opencode run" }, + body: { + title: "oh-my-opencode run", + permission: [ + { permission: "question", action: "deny", pattern: "*" }, + ], + }, }) }) @@ -116,7 +130,9 @@ describe("resolveSession", () => { const result = resolveSession({ client: mockClient }) // then - await expect(result).rejects.toThrow("Failed to create session after all retries") + await Promise.resolve( + expect(result).rejects.toThrow("Failed to create session after all retries") + ) expect(mockClient.session.create).toHaveBeenCalledTimes(3) }) @@ -134,7 +150,9 @@ describe("resolveSession", () => { const result = resolveSession({ client: mockClient }) // then - await expect(result).rejects.toThrow("Failed to create session after all retries") + await Promise.resolve( + expect(result).rejects.toThrow("Failed to create session after all retries") + ) expect(mockClient.session.create).toHaveBeenCalledTimes(3) }) }) diff --git a/src/cli/run/session-resolver.ts b/src/cli/run/session-resolver.ts index 31bd5a2c..1ec07199 100644 --- a/src/cli/run/session-resolver.ts +++ b/src/cli/run/session-resolver.ts @@ -19,14 +19,18 @@ export async function resolveSession(options: { return sessionId } - let lastError: unknown for (let attempt = 1; attempt <= SESSION_CREATE_MAX_RETRIES; attempt++) { const res = await client.session.create({ - body: { title: "oh-my-opencode run" }, + body: { + title: "oh-my-opencode run", + // In CLI run mode there's no TUI to answer questions. + permission: [ + { permission: "question", action: "deny" as const, pattern: "*" }, + ], + } as any, }) if (res.error) { - lastError = res.error console.error( pc.yellow(`Session create attempt ${attempt}/${SESSION_CREATE_MAX_RETRIES} failed:`) ) @@ -44,9 +48,6 @@ export async function resolveSession(options: { return res.data.id } - lastError = new Error( - `Unexpected response: ${JSON.stringify(res, null, 2)}` - ) console.error( pc.yellow( `Session create attempt ${attempt}/${SESSION_CREATE_MAX_RETRIES}: No session ID returned` diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index c4db9056..d67ae2ad 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -1412,14 +1412,14 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { let manager: BackgroundManager let mockClient: ReturnType - function createMockClient() { - return { - session: { - create: async () => ({ data: { id: `ses_${crypto.randomUUID()}` } }), - get: async () => ({ data: { directory: "/test/dir" } }), - prompt: async () => ({}), - promptAsync: async () => ({}), - messages: async () => ({ data: [] }), + function createMockClient() { + return { + session: { + create: async (_args?: any) => ({ data: { id: `ses_${crypto.randomUUID()}` } }), + get: async () => ({ data: { directory: "/test/dir" } }), + prompt: async () => ({}), + promptAsync: async () => ({}), + messages: async () => ({ data: [] }), todo: async () => ({ data: [] }), status: async () => ({ data: {} }), abort: async () => ({}), @@ -1520,6 +1520,55 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { }) describe("task transitions pending→running when slot available", () => { + test("should inherit parent session permission rules (and force deny question)", async () => { + // given + const createCalls: any[] = [] + const parentPermission = [ + { permission: "question", action: "allow" as const, pattern: "*" }, + { permission: "plan_enter", action: "deny" as const, pattern: "*" }, + ] + + const customClient = { + session: { + create: async (args?: any) => { + createCalls.push(args) + return { data: { id: `ses_${crypto.randomUUID()}` } } + }, + get: async () => ({ data: { directory: "/test/dir", permission: parentPermission } }), + prompt: async () => ({}), + promptAsync: async () => ({}), + messages: async () => ({ data: [] }), + todo: async () => ({ data: [] }), + status: async () => ({ data: {} }), + abort: async () => ({}), + }, + } + manager.shutdown() + manager = new BackgroundManager({ client: customClient, directory: tmpdir() } as unknown as PluginInput, { + defaultConcurrency: 5, + }) + + const input = { + description: "Test task", + prompt: "Do something", + agent: "test-agent", + parentSessionID: "parent-session", + parentMessageID: "parent-message", + } + + // when + await manager.launch(input) + await new Promise(resolve => setTimeout(resolve, 50)) + + // then + expect(createCalls).toHaveLength(1) + const permission = createCalls[0]?.body?.permission + expect(permission).toEqual([ + { permission: "plan_enter", action: "deny", pattern: "*" }, + { permission: "question", action: "deny", pattern: "*" }, + ]) + }) + test("should transition first task to running immediately", async () => { // given const config = { defaultConcurrency: 5 } diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 0604c876..cfe8808a 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -236,13 +236,17 @@ export class BackgroundManager { const parentDirectory = parentSession?.data?.directory ?? this.directory log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`) + const inheritedPermission = (parentSession as any)?.data?.permission + const permissionRules = Array.isArray(inheritedPermission) + ? inheritedPermission.filter((r: any) => r?.permission !== "question") + : [] + permissionRules.push({ permission: "question", action: "deny" as const, pattern: "*" }) + const createResult = await this.client.session.create({ body: { parentID: input.parentSessionID, title: `${input.description} (@${input.agent} subagent)`, - permission: [ - { permission: "question", action: "deny" as const, pattern: "*" }, - ], + permission: permissionRules, } as any, query: { directory: parentDirectory, diff --git a/src/features/background-agent/spawner.test.ts b/src/features/background-agent/spawner.test.ts new file mode 100644 index 00000000..334f3762 --- /dev/null +++ b/src/features/background-agent/spawner.test.ts @@ -0,0 +1,65 @@ +import { describe, test, expect } from "bun:test" + +import { createTask, startTask } from "./spawner" + +describe("background-agent spawner.startTask", () => { + test("should inherit parent session permission rules (and force deny question)", async () => { + //#given + const createCalls: any[] = [] + const parentPermission = [ + { permission: "question", action: "allow" as const, pattern: "*" }, + { permission: "plan_enter", action: "deny" as const, pattern: "*" }, + ] + + const client = { + session: { + get: async () => ({ data: { directory: "/parent/dir", permission: parentPermission } }), + create: async (args?: any) => { + createCalls.push(args) + return { data: { id: "ses_child" } } + }, + promptAsync: async () => ({}), + }, + } + + const task = createTask({ + description: "Test task", + prompt: "Do work", + agent: "explore", + parentSessionID: "ses_parent", + parentMessageID: "msg_parent", + }) + + const item = { + task, + input: { + description: task.description, + prompt: task.prompt, + agent: task.agent, + parentSessionID: task.parentSessionID, + parentMessageID: task.parentMessageID, + parentModel: task.parentModel, + parentAgent: task.parentAgent, + model: task.model, + }, + } + + const ctx = { + client, + directory: "/fallback", + concurrencyManager: { release: () => {} }, + tmuxEnabled: false, + onTaskError: () => {}, + } + + //#when + await startTask(item as any, ctx as any) + + //#then + expect(createCalls).toHaveLength(1) + expect(createCalls[0]?.body?.permission).toEqual([ + { permission: "plan_enter", action: "deny", pattern: "*" }, + { permission: "question", action: "deny", pattern: "*" }, + ]) + }) +}) diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index 477aafc1..1b6773fb 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -58,13 +58,17 @@ export async function startTask( const parentDirectory = parentSession?.data?.directory ?? directory log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`) + const inheritedPermission = (parentSession as any)?.data?.permission + const permissionRules = Array.isArray(inheritedPermission) + ? inheritedPermission.filter((r: any) => r?.permission !== "question") + : [] + permissionRules.push({ permission: "question", action: "deny" as const, pattern: "*" }) + const createResult = await client.session.create({ body: { parentID: input.parentSessionID, title: `Background: ${input.description}`, - permission: [ - { permission: "question", action: "deny" as const, pattern: "*" }, - ], + permission: permissionRules, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, query: {