diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3927f615a080..36e2dd6ee659 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -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>) { const toggles = new Map() + const sessions = new Set([sessionID]) let error: string | undefined + async function belongsToSessionTree(candidate: string) { + const path: string[] = [] + const seen = new Set() + 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" && @@ -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({ diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index bd5847e2723c..ed3e0bc66e7a 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -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. @@ -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() + 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 }) =>