From 9963b0271a78244f773e6192721376618d0a3549 Mon Sep 17 00:00:00 2001 From: Josh Holmer Date: Sun, 10 May 2026 00:50:32 -0400 Subject: [PATCH] fix(cli): apply `--auto` flag to subagents from Task tool --- .changeset/auto-subagent-permissions.md | 5 + packages/opencode/src/cli/cmd/run.ts | 33 ++-- .../opencode/src/kilocode/cli/run-auto.ts | 47 +++++ .../test/kilocode/cli-run-auto-helper.test.ts | 59 ++++++ .../opencode/test/kilocode/run-auto.test.ts | 177 ++++++++++++++++++ 5 files changed, 308 insertions(+), 13 deletions(-) create mode 100644 .changeset/auto-subagent-permissions.md create mode 100644 packages/opencode/src/kilocode/cli/run-auto.ts create mode 100644 packages/opencode/test/kilocode/cli-run-auto-helper.test.ts create mode 100644 packages/opencode/test/kilocode/run-auto.test.ts diff --git a/.changeset/auto-subagent-permissions.md b/.changeset/auto-subagent-permissions.md new file mode 100644 index 00000000000..73c865fdfbb --- /dev/null +++ b/.changeset/auto-subagent-permissions.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Auto-approve Task subagent tool permissions when running `kilo run --auto`. diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 43fc4a96212..da5c533d35b 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -28,6 +28,7 @@ import { TodoWriteTool } from "../../tool/todo" import { Locale } from "@/util/locale" import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change import { AppRuntime } from "@/effect/app-runtime" +import { KiloRunAuto } from "@/kilocode/cli/run-auto" // kilocode_change type ToolProps = { input: Tool.InferParameters @@ -477,6 +478,9 @@ export const RunCommand = cmd({ 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 end if (part.sessionID !== sessionID) continue if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { @@ -571,25 +575,27 @@ export const RunCommand = cmd({ if (event.type === "permission.asked") { const permission = event.properties - if (permission.sessionID !== sessionID) continue - + // kilocode_change start - In auto mode, approve root and tracked Task child permissions only if (args.auto) { - // kilocode_change - In auto mode, automatically approve all permissions without prompting + if (!KiloRunAuto.allowed(auto, permission.sessionID)) continue await sdk.permission.reply({ requestID: permission.id, reply: "once", }) - } else { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL + - `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, - ) - await sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - }) + continue } + + if (permission.sessionID !== sessionID) continue + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL + + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, + ) + await sdk.permission.reply({ + requestID: permission.id, + reply: "reject", + }) + // kilocode_change end } // kilocode_change start - network retry handling if (event.type === "session.network.asked") { @@ -682,6 +688,7 @@ export const RunCommand = cmd({ UI.error("Session not found") process.exit(1) } + const auto = KiloRunAuto.create(sessionID) // kilocode_change await share(sdk, sessionID) loop().catch((e) => { diff --git a/packages/opencode/src/kilocode/cli/run-auto.ts b/packages/opencode/src/kilocode/cli/run-auto.ts new file mode 100644 index 00000000000..84014997ef3 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/run-auto.ts @@ -0,0 +1,47 @@ +// kilocode_change - new file +export namespace KiloRunAuto { + export interface State { + root: string + sessions: Set + } + + export interface Part { + type?: string + tool?: string + sessionID?: string + state?: unknown + } + + export function create(root: string): State { + return { + root, + sessions: new Set([root]), + } + } + + export function allowed(state: State, sessionID: string) { + return state.sessions.has(sessionID) + } + + export function track(state: State, part: Part) { + if (part.type !== "tool") return + if (part.tool !== "task") return + if (part.sessionID !== state.root) return + const id = child(meta(part.state)) + if (!id) return + state.sessions.add(id) + } + + function meta(state: unknown) { + if (!state || typeof state !== "object") return + return (state as Record).metadata + } + + function child(meta: unknown) { + if (!meta || typeof meta !== "object") return + const id = (meta as Record).sessionId + if (typeof id !== "string") return + if (!id) return + return id + } +} diff --git a/packages/opencode/test/kilocode/cli-run-auto-helper.test.ts b/packages/opencode/test/kilocode/cli-run-auto-helper.test.ts new file mode 100644 index 00000000000..2b843848ac4 --- /dev/null +++ b/packages/opencode/test/kilocode/cli-run-auto-helper.test.ts @@ -0,0 +1,59 @@ +// kilocode_change - new file +import { describe, expect, test } from "bun:test" +import { KiloRunAuto } from "../../src/kilocode/cli/run-auto" + +describe("KiloRunAuto", () => { + test("tracks task child sessions without allowing unrelated sessions", () => { + const state = KiloRunAuto.create("ses_root") + + expect(KiloRunAuto.allowed(state, "ses_root")).toBe(true) + expect(KiloRunAuto.allowed(state, "ses_child")).toBe(false) + + KiloRunAuto.track(state, { + type: "tool", + tool: "task", + sessionID: "ses_root", + state: { + metadata: { + sessionId: "ses_child", + }, + }, + }) + + expect(KiloRunAuto.allowed(state, "ses_child")).toBe(true) + expect(KiloRunAuto.allowed(state, "ses_other")).toBe(false) + }) + + test("ignores malformed or non-root task metadata", () => { + const state = KiloRunAuto.create("ses_root") + + KiloRunAuto.track(state, { + type: "tool", + tool: "task", + sessionID: "ses_root", + state: { + metadata: { + sessionId: "", + }, + }, + }) + KiloRunAuto.track(state, { + type: "tool", + tool: "task", + sessionID: "ses_other", + state: { + metadata: { + sessionId: "ses_wrong", + }, + }, + }) + KiloRunAuto.track(state, { + type: "text", + sessionID: "ses_root", + state: {}, + }) + + expect(KiloRunAuto.allowed(state, "ses_wrong")).toBe(false) + expect(KiloRunAuto.allowed(state, "")).toBe(false) + }) +}) diff --git a/packages/opencode/test/kilocode/run-auto.test.ts b/packages/opencode/test/kilocode/run-auto.test.ts new file mode 100644 index 00000000000..4b6788d2a50 --- /dev/null +++ b/packages/opencode/test/kilocode/run-auto.test.ts @@ -0,0 +1,177 @@ +// kilocode_change - new file +import { afterEach, describe, expect, mock, test } from "bun:test" + +type Event = { + type: string + properties: Record +} + +function feed() { + const list: T[] = [] + const wait: Array<() => void> = [] + const state = { done: false } + + return { + push(item: T) { + list.push(item) + while (wait.length) wait.shift()?.() + }, + end() { + state.done = true + while (wait.length) wait.shift()?.() + }, + async *stream() { + while (!state.done || list.length) { + if (list.length) { + yield list.shift() as T + continue + } + await new Promise((resolve) => wait.push(resolve)) + } + }, + } +} + +function task(child: string): Event { + return { + type: "message.part.updated", + properties: { + part: { + id: "prt_task", + type: "tool", + tool: "task", + sessionID: "ses_root", + state: { + status: "running", + input: { + description: "inspect bug", + prompt: "check child permissions", + subagent_type: "general", + }, + metadata: { + sessionId: child, + }, + time: { start: 0 }, + }, + }, + }, + } +} + +function permission(id: string, sessionID: string): Event { + return { + type: "permission.asked", + properties: { + id, + sessionID, + permission: "bash", + patterns: ["npm test"], + metadata: { command: "npm test" }, + always: ["npm *"], + }, + } +} + +function idle(): Event { + return { + type: "session.status", + properties: { + sessionID: "ses_root", + status: { type: "idle" }, + }, + } +} + +function args() { + return { + _: [], + $0: "kilo", + message: ["hi"], + command: undefined, + continue: false, + session: "ses_root", + fork: false, + "cloud-fork": false, + cloudFork: false, + share: false, + model: undefined, + agent: undefined, + format: "json", + file: undefined, + title: undefined, + attach: "http://127.0.0.1:4096", + password: undefined, + dir: undefined, + port: undefined, + variant: undefined, + thinking: false, + auto: true, + "dangerously-skip-permissions": false, + dangerouslySkipPermissions: false, + "--": [], + } +} + +const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY") + +afterEach(() => { + if (tty) { + Object.defineProperty(process.stdin, "isTTY", tty) + return + } + delete (process.stdin as { isTTY?: boolean }).isTTY +}) + +async function run(sdk: Record) { + mock.module("@kilocode/sdk/v2", () => ({ + createKiloClient: () => sdk, + })) + + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }) + + const key = JSON.stringify({ time: Date.now(), rand: Math.random() }) + const { RunCommand } = await import(`../../src/cli/cmd/run?${key}`) + return RunCommand.handler(args() as never) +} + +describe("cli run auto permissions", () => { + test("auto approves tracked subagent permissions and ignores unrelated sessions", async () => { + const q = feed() + const calls: Array<{ requestID: string; reply: string }> = [] + const done = Promise.withResolvers() + + const sdk = { + config: { + get: async () => ({ data: { share: "manual" } }), + }, + event: { + subscribe: async () => ({ stream: q.stream() }), + }, + permission: { + reply: async (input: { requestID: string; reply: string }) => { + calls.push(input) + if (input.requestID === "perm_child") done.resolve() + return { data: true } + }, + }, + session: { + prompt: async () => { + q.push(task("ses_child")) + q.push(permission("perm_other", "ses_other")) + q.push(permission("perm_child", "ses_child")) + q.push(idle()) + await Promise.race([done.promise, new Promise((resolve) => setTimeout(resolve, 25))]) + q.end() + return { data: undefined } + }, + }, + } + + await run(sdk) + + expect(calls).toEqual([{ requestID: "perm_child", reply: "once" }]) + }) +})