Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/subagent-permission-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Fail subagent permission prompts in headless `kilo run` immediately instead of hanging forever, and approve subagent permission prompts under `--dangerously-skip-permissions`
35 changes: 32 additions & 3 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.
import { event as normalizeEvent } from "./run/event"
import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change
import { KiloRunAuto } from "@/kilocode/cli/run-auto" // kilocode_change
import { KiloHeadless } from "@/kilocode/permission/headless" // kilocode_change
import { KiloRun, KiloRunDaemon } from "@/kilocode/cli/cmd/run" // kilocode_change

const runtimeTask = import("./run/runtime")
Expand Down Expand Up @@ -700,7 +701,10 @@ export const RunCommand = effectCmd({
process.exit(1)
}
const sessionID = sess.id
const auto = KiloRunAuto.create(sessionID) // kilocode_change
// kilocode_change start - track Task children; plain headless runs deny subagent asks instead of hanging (#11903)
const auto = KiloRunAuto.create(sessionID)
if (!args.attach && !args.auto && !args["dangerously-skip-permissions"]) KiloHeadless.mark(sessionID)
Comment thread
marius-kilocode marked this conversation as resolved.
// kilocode_change end

function emit(type: string, data: Record<string, unknown>) {
if (args.format === "json") {
Expand Down Expand Up @@ -746,8 +750,8 @@ export const RunCommand = effectCmd({

if (event.type === "message.part.updated") {
const part = event.properties.part
// kilocode_change start - track Task child sessions for --auto permission replies
if (args.auto) KiloRunAuto.track(auto, part)
// kilocode_change start - track Task child sessions so permission replies can target them
KiloRunAuto.track(auto, part)
// kilocode_change end
if (part.sessionID !== sessionID) continue

Expand Down Expand Up @@ -851,6 +855,31 @@ export const RunCommand = effectCmd({
}
// kilocode_change end

// kilocode_change start - answer tracked Task child asks too, so subagents don't hang (#11903)
// Covers daemon/attach modes where the server evaluates permissions in another
// process and the in-process KiloHeadless deny cannot apply.
if (permission.sessionID !== sessionID) {
if (!KiloRunAuto.allowed(auto, permission.sessionID)) continue
if (args["dangerously-skip-permissions"]) {
await client.permission.reply({
requestID: permission.id,
reply: "once",
})
continue
}
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
`subagent permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
)
await client.permission.reply({
requestID: permission.id,
reply: "reject",
})
continue
}
// kilocode_change end

if (permission.sessionID !== sessionID) continue

if (args["dangerously-skip-permissions"]) {
Expand Down
45 changes: 45 additions & 0 deletions packages/opencode/src/kilocode/permission/headless.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Database, eq } from "@/storage/db"
import { SessionTable } from "@/session/session.sql"
import type { SessionID } from "@/session/schema"

/**
* Headless roots (#11903).
*
* Root sessions driven by a client that cannot answer subagent permission
* prompts (plain `kilo run`). Permission asks originating from their child
* sessions must fail with DeniedError instead of blocking forever on a reply
* that never comes. Interactive clients (TUI, extension) never mark sessions
* here, so their subagent prompts stay answerable.
*/
export namespace KiloHeadless {
const roots = new Set<string>()

export function mark(id: string) {
roots.add(id)
}

export function clear(id: string) {
roots.delete(id)
}

/** True when `id` is a subagent session whose root run has no attached human. */
export function denies(id: string): boolean {
if (roots.size === 0) return false
if (roots.has(id)) return false
for (let parent = lookup(id); parent; parent = lookup(parent)) {
if (roots.has(parent)) return true
}
return false
}

function lookup(id: string) {
const row = Database.use((db) =>
db
.select({ parent: SessionTable.parent_id })
.from(SessionTable)
.where(eq(SessionTable.id, id as SessionID))
.get(),
)
return row?.parent ?? undefined
}
}
7 changes: 7 additions & 0 deletions packages/opencode/src/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionID } from "./schema"
// kilocode_change start
import { ConfigProtection } from "@/kilocode/permission/config-paths"
import { KiloHeadless } from "@/kilocode/permission/headless"
import { drainCovered } from "@/kilocode/permission/drain"
import { ReadPermission } from "@/kilocode/permission/read"
import { ExternalDirectoryPermission } from "@/kilocode/permission/external-directory"
Expand Down Expand Up @@ -276,6 +277,12 @@ export const layer = Layer.effect(

if (!needsAsk) return

// kilocode_change start - headless subagent asks fail instead of queuing for a reply that never comes (#11903)
if (KiloHeadless.denies(request.sessionID)) {
return yield* new DeniedError({ ruleset: subset(request.permission, ruleset) })
}
// kilocode_change end

const id = request.id ?? PermissionID.ascending()
const info: Request = {
id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ import { SyncEvent } from "../../src/sync"
import { Ripgrep } from "../../src/file/ripgrep"
import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate"
import { KiloHeadless } from "../../src/kilocode/permission/headless"
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
import { provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server"

void Log.init({ print: false })
Expand Down Expand Up @@ -290,3 +292,123 @@ it.live("active tool calls use permissions changed after model streaming starts"
},
),
)

const worker = (mode: "subagent" | "all"): AgentSvc.Info => ({
name: "worker",
mode,
permission: Permission.fromConfig({ bash: "ask" }),
options: {},
})

const bash = (sessionID: Session.Info["id"]) => ({
sessionID,
permission: "bash",
patterns: ["echo 1"],
always: ["echo 1"],
metadata: {},
})

// Reproduces #11903: a sync subagent hitting an "ask" rule in a headless run
// used to block forever on a permission prompt no client would ever answer.
it.live("headless run: subagent permission asks fail instead of waiting forever", () =>
provideTmpdirServer(
Effect.fnUntraced(function* () {
const permission = yield* Permission.Service
const sessions = yield* Session.Service
const root = yield* sessions.create({ title: "Root" })
const child = yield* sessions.create({ parentID: root.id, title: "Subagent" })
KiloHeadless.mark(root.id)

// mode "all" agents are valid subagents too; the deny must not key off agent mode
const agent = worker("all")
const err = yield* awaitWithTimeout(
KiloSessionPrompt.askPermission({
permission,
agents: { get: () => Effect.succeed(agent) },
sessions,
agent,
session: child,
request: bash(child.id),
}).pipe(Effect.flip),
"subagent permission ask queued waiting for a human reply instead of failing",
)

expect(err).toBeInstanceOf(Permission.DeniedError)
expect(yield* permission.list()).toEqual([])
expect(KiloHeadless.denies(child.id)).toBe(true)
expect(KiloHeadless.denies(root.id)).toBe(false)

KiloHeadless.clear(root.id)
}),
{ git: true },
),
)

it.live("interactive run: subagent permission asks still queue for a human reply", () =>
provideTmpdirServer(
Effect.fnUntraced(function* () {
const permission = yield* Permission.Service
const sessions = yield* Session.Service
const root = yield* sessions.create({ title: "Root" })
const child = yield* sessions.create({ parentID: root.id, title: "Subagent" })

const agent = worker("subagent")
const fiber = yield* KiloSessionPrompt.askPermission({
permission,
agents: { get: () => Effect.succeed(agent) },
sessions,
agent,
session: child,
request: bash(child.id),
}).pipe(Effect.forkScoped)

const pending = yield* pollWithTimeout(
Effect.gen(function* () {
const list = yield* permission.list()
return list.find((item) => item.sessionID === child.id)
}),
"subagent permission ask was never surfaced",
)
yield* permission.reply({ requestID: pending.id, reply: "reject" })

const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
}),
{ git: true },
),
)

it.live("headless run: root session permission asks still queue (only subagents fail)", () =>
provideTmpdirServer(
Effect.fnUntraced(function* () {
const permission = yield* Permission.Service
const sessions = yield* Session.Service
const root = yield* sessions.create({ title: "Root" })
KiloHeadless.mark(root.id)

const agent = { ...worker("subagent"), mode: "primary" as const }
const fiber = yield* KiloSessionPrompt.askPermission({
permission,
agents: { get: () => Effect.succeed(agent) },
sessions,
agent,
session: root,
request: bash(root.id),
}).pipe(Effect.forkScoped)

const pending = yield* pollWithTimeout(
Effect.gen(function* () {
const list = yield* permission.list()
return list.find((item) => item.sessionID === root.id)
}),
"root permission ask was never surfaced",
)
yield* permission.reply({ requestID: pending.id, reply: "reject" })

const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
KiloHeadless.clear(root.id)
}),
{ git: true },
),
)
Loading