Merge pull request #10787 from Kilo-Org/picayune-yacht

fix(vscode): route worktree permission approvals correctly
This commit is contained in:
Marius
2026-06-08 13:04:32 +02:00
committed by GitHub
5 changed files with 91 additions and 20 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Route Agent Manager permission approvals to the worktree that created the request.
+10 -8
View File
@@ -1165,7 +1165,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Subscribe to SSE events for this webview (filtered by tracked sessions)
this.unsubscribeEvent = this.connectionService.onEventFiltered(
(event) => {
(event, directory) => {
// Preserve the request origin even when a worktree session is not tracked yet.
// Manual replies must target the Instance that owns the pending permission.
if (event.type === "permission.asked" && directory) {
this.permissionDirectories.set(event.properties.id, directory)
}
if (event.type === "permission.replied") {
this.permissionDirectories.delete(event.properties.requestID)
}
// Remote status events are global and should always pass through
if (event.type === "kilo-sessions.remote-status-changed") return true
const sessionId = this.connectionService.resolveEventSessionId(event)
@@ -2874,13 +2883,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// let a foreign session through if it was accidentally tracked.
if (isEventFromForeignProject(event, this.projectID)) return
if (event.type === "permission.asked" && directory) {
this.permissionDirectories.set(event.properties.id, directory)
}
if (event.type === "permission.replied") {
this.permissionDirectories.delete(event.properties.requestID)
}
if (event.type === "mcp.browser.open.failed") {
McpOAuth.openMcpOAuthUrlOnce(event.properties.url)
return
@@ -83,12 +83,12 @@ export function registerToggleAutoApprove(
return active
}
const unsubscribe = connectionService.onEvent((event: Event) => {
const unsubscribe = connectionService.onEvent((event: Event, directory?: string) => {
if (!active) return
if (event.type !== "permission.asked") return
const client = tryGetClient(connectionService)
if (!client) return
const dir = resolve(event.properties.sessionID)
const dir = directory ?? resolve(event.properties.sessionID)
client.permission.reply({ requestID: event.properties.id, directory: dir, reply: "once" }).catch((err) => {
console.error("[Kilo New] toggleAutoApprove: failed to auto-reply:", err)
})
@@ -80,13 +80,13 @@ function context() {
}
function connection(client: KiloClient | null) {
const listeners: Array<(event: Event) => void> = []
const listeners: Array<(event: Event, directory?: string) => void> = []
const svc = {
getClient: () => {
if (!client) throw new Error("not connected")
return client
},
onEvent: (listener: (event: Event) => void) => {
onEvent: (listener: (event: Event, directory?: string) => void) => {
listeners.push(listener)
return () => {
const index = listeners.indexOf(listener)
@@ -97,8 +97,8 @@ function connection(client: KiloClient | null) {
return {
svc,
emit(event: Event) {
for (const listener of listeners) listener(event)
emit(event: Event, directory?: string) {
for (const listener of listeners) listener(event, directory)
},
}
}
@@ -152,6 +152,26 @@ describe("registerToggleAutoApprove", () => {
expect(env.messages).toContain("Auto-approve enabled")
})
it("uses the SSE directory for worktree permissions before session mappings are available", () => {
config(true)
const replies: unknown[] = []
const conn = connection(client({ reply: async (args) => replies.push(args) }))
registerToggleAutoApprove(
context(),
conn.svc,
() => "/workspace",
() => ["/workspace"],
)
conn.emit(asked("perm_worktree", "ses_worktree"), "/workspace/.kilo/worktrees/feature")
conn.emit(asked("perm_child", "ses_child"), "/workspace/.kilo/worktrees/feature")
expect(replies).toEqual([
{ requestID: "perm_worktree", directory: "/workspace/.kilo/worktrees/feature", reply: "once" },
{ requestID: "perm_child", directory: "/workspace/.kilo/worktrees/feature", reply: "once" },
])
})
it("cancels pending permission drains when disabled during an enable generation", async () => {
config(false)
const gate = defer<{ data: Permission[] }>()
@@ -1,6 +1,7 @@
import { describe, it, expect } from "bun:test"
import {
fetchAndSendPendingPermissions,
handlePermissionResponse,
recoverablePermissions,
recoveryDirs,
type RecoverablePermission,
@@ -20,7 +21,12 @@ function pending(id: string, sessionID: string, permission = "bash"): Recoverabl
}
}
function permissionClient(permsPerDir: Record<string, ReturnType<typeof pending>[]>, queries: string[]) {
function permissionClient(
permsPerDir: Record<string, ReturnType<typeof pending>[]>,
queries: string[],
saves: unknown[] = [],
replies: unknown[] = [],
) {
return {
permission: {
list: async (args?: { directory?: string }) => {
@@ -28,8 +34,14 @@ function permissionClient(permsPerDir: Record<string, ReturnType<typeof pending>
queries.push(dir)
return { data: permsPerDir[dir] ?? [] }
},
saveAlwaysRules: async () => ({ data: true }),
reply: async () => ({ data: true }),
saveAlwaysRules: async (args: unknown) => {
saves.push(args)
return { data: true }
},
reply: async (args: unknown) => {
replies.push(args)
return { data: true }
},
},
}
}
@@ -37,8 +49,10 @@ function permissionClient(permsPerDir: Record<string, ReturnType<typeof pending>
function client(
permsPerDir: Record<string, ReturnType<typeof pending>[]>,
queries: string[],
saves: unknown[] = [],
replies: unknown[] = [],
): PermissionContext["client"] {
return permissionClient(permsPerDir, queries) as unknown as PermissionContext["client"]
return permissionClient(permsPerDir, queries, saves, replies) as unknown as PermissionContext["client"]
}
function ctx(opts: {
@@ -49,8 +63,10 @@ function ctx(opts: {
}) {
const messages: unknown[] = []
const queries: string[] = []
const saves: unknown[] = []
const replies: unknown[] = []
const perms = opts.permsPerDir ?? {}
const sdk = client(perms, queries)
const sdk = client(perms, queries, saves, replies)
const permDirs = new Map<string, string>()
const fake: PermissionContext = {
@@ -72,7 +88,7 @@ function ctx(opts: {
},
}
return { fake, messages, queries, permDirs }
return { fake, messages, queries, saves, replies, permDirs }
}
describe("recoveryDirs", () => {
@@ -94,6 +110,34 @@ describe("recoveryDirs", () => {
})
})
describe("handlePermissionResponse", () => {
it("uses the recorded SSE directory instead of a stale session fallback", async () => {
const { fake, replies, permDirs } = ctx({ tracked: ["s1"] })
permDirs.set("p1", "/workspace/.kilo/worktrees/feature")
await handlePermissionResponse(fake, "p1", "s1", "once", [], [])
expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }])
})
it("saves selected rules and replies in the recorded SSE directory", async () => {
const { fake, saves, replies, permDirs } = ctx({ tracked: ["s1"] })
permDirs.set("p1", "/workspace/.kilo/worktrees/feature")
await handlePermissionResponse(fake, "p1", "s1", "reject", ["bun *"], ["rm *"])
expect(saves).toEqual([
{
requestID: "p1",
directory: "/workspace/.kilo/worktrees/feature",
approvedAlways: ["bun *"],
deniedAlways: ["rm *"],
},
])
expect(replies).toEqual([{ requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature" }])
})
})
describe("recoverablePermissions", () => {
it("filters out untracked permissions", () => {
const seen = new Set<string>()