Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,8 +696,25 @@ export const RunCommand = effectCmd({
// created, and replies issued from inside the loop must use that client.
async function loop(client: OpencodeClient, events: Awaited<ReturnType<typeof sdk.event.subscribe>>) {
const toggles = new Map<string, boolean>()
const sessions = new Set([sessionID])
let error: string | undefined

async function belongsToSessionTree(candidate: string) {
const path: string[] = []
const seen = new Set<string>()
let current = candidate
while (!sessions.has(current)) {
if (seen.has(current)) return false
seen.add(current)
path.push(current)
const result = await client.session.get({ sessionID: current }).catch(() => undefined)
if (!result?.data?.parentID) return false
current = result.data.parentID
}
path.forEach((id) => sessions.add(id))
return true
}

for await (const event of events.stream) {
if (
event.type === "message.updated" &&
Expand Down Expand Up @@ -795,7 +812,7 @@ export const RunCommand = effectCmd({

if (event.type === "permission.asked") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue
if (!(await belongsToSessionTree(permission.sessionID))) continue

if (auto) {
await client.permission.reply({
Expand Down
108 changes: 108 additions & 0 deletions packages/opencode/test/cli/run/run-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
// `opencode.run(message, opts?)` to spawn `bun src/index.ts run ...` with
// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline.
import { describe, expect } from "bun:test"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import { reply } from "../../lib/llm-server"
import { cliIt } from "../../lib/cli-process"
import { pollWithTimeout } from "../../lib/effect"

describe("opencode run (non-interactive subprocess)", () => {
// Happy path: prompt completes, output reaches stdout, process exits 0.
Expand Down Expand Up @@ -277,6 +279,112 @@ describe("opencode run (non-interactive subprocess)", () => {
60_000,
)

// Regression for #41730: --auto permissions must cascade to subagents.
// Without the fix, the child session's permission.asked event is filtered
// out by the session-ID check and the process hangs forever.
cliIt.live(
"--auto approves permissions requested by subagent sessions",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
reply().tool("task", {
description: "Run echo command",
prompt: "Run the bash command: echo hello",
subagent_type: "general",
}),
)
yield* llm.push(reply().tool("bash", { command: "echo hello", description: "Print hello" }))
yield* llm.text("subagent finished")
yield* llm.text("parent done")

const result = yield* opencode.run("spawn a subagent to run bash", {
permission: { bash: "ask" },
extraArgs: ["--dangerously-skip-permissions"],
})

opencode.expectExit(result, 0)
expect(result.stdout).toContain("parent done")
}),
60_000,
)

// Without --auto, subagent permissions should be auto-rejected (not hung).
cliIt.live(
"subagent permissions are auto-rejected without --auto",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
reply().tool("task", {
description: "Run echo command",
prompt: "Run the bash command: echo hello",
subagent_type: "general",
}),
)
yield* llm.push(reply().tool("bash", { command: "echo hello", description: "Print hello" }))
yield* llm.text("subagent finished")
yield* llm.text("parent done")

const result = yield* opencode.run("spawn a subagent to run bash", {
permission: { bash: "ask" },
})

opencode.expectExit(result, 0)
expect(result.stderr).toContain("auto-rejecting")
}),
60_000,
)

cliIt.live(
"--auto leaves permission requests from unrelated sessions pending",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const gate = Promise.withResolvers<void>()
yield* llm.push(
reply().wait(gate.promise).tool("bash", { command: "echo root", description: "Continue the root session" }),
)
yield* llm.push(reply().tool("bash", { command: "echo unrelated", description: "Wait for unrelated approval" }))
yield* llm.text("root done")

const server = yield* opencode.serve()
const sdk = createOpencodeClient({ baseUrl: server.url, directory: home })
const permission = [{ permission: "bash", pattern: "*", action: "ask" as const }]
const root = yield* Effect.promise(() => sdk.session.create({ title: "root", permission }))
const unrelated = yield* Effect.promise(() => sdk.session.create({ title: "unrelated", permission }))
expect(root.data?.id).toBeDefined()
expect(unrelated.data?.id).toBeDefined()

const run = yield* opencode.startRun("run the root tool", {
extraArgs: ["--attach", server.url, "--session", root.data!.id, "--dangerously-skip-permissions"],
})
yield* llm.wait(1)

yield* Effect.promise(() =>
sdk.session.promptAsync({
sessionID: unrelated.data!.id,
model: { providerID: "test", modelID: "test-model" },
agent: "build",
parts: [{ type: "text", text: "run the unrelated tool" }],
}),
)

const pending = yield* pollWithTimeout(
Effect.promise(async () =>
(await sdk.permission.list()).data?.find((item) => item.sessionID === unrelated.data!.id),
),
"unrelated session did not request permission",
)

gate.resolve()
yield* llm.wait(3)

const remaining = yield* Effect.promise(() => sdk.permission.list())
expect(remaining.data?.some((item) => item.id === pending.id)).toBe(true)
const result = yield* run.result
opencode.expectExit(result, 0)
}),
60_000,
)

cliIt.live(
"attach mode sends client-local file contents without a shared path",
({ home, llm, opencode }) =>
Expand Down
Loading