feat(cli/run, background-agent): manage session permissions for CLI and background tasks

- 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
This commit is contained in:
YeonGyu-Kim
2026-02-08 18:41:26 +09:00
parent e663d7b335
commit 1324fee30f
6 changed files with 169 additions and 28 deletions

View File

@@ -1,6 +1,8 @@
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"
import { resolveSession } from "./session-resolver"
import type { OpencodeClient } from "./types"
/// <reference types="bun-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)
})
})

View File

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

View File

@@ -1412,14 +1412,14 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
let manager: BackgroundManager
let mockClient: ReturnType<typeof createMockClient>
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 }

View File

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

View File

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

View File

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