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/route-worktree-permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Route Agent Manager permission approvals to the worktree that created the request.
18 changes: 10 additions & 8 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1202,7 +1202,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)
Expand Down Expand Up @@ -3151,13 +3160,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
Expand Down
4 changes: 2 additions & 2 deletions packages/kilo-vscode/src/commands/toggle-auto-approve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
28 changes: 24 additions & 4 deletions packages/kilo-vscode/tests/unit/auto-approve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
},
}
}
Expand Down Expand Up @@ -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[] }>()
Expand Down
56 changes: 50 additions & 6 deletions packages/kilo-vscode/tests/unit/permission-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from "bun:test"
import {
fetchAndSendPendingPermissions,
handlePermissionResponse,
recoverablePermissions,
recoveryDirs,
type RecoverablePermission,
Expand All @@ -20,25 +21,38 @@ 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 }) => {
const dir = args?.directory ?? ""
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 }
},
},
}
}

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: {
Expand All @@ -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 = {
Expand All @@ -72,7 +88,7 @@ function ctx(opts: {
},
}

return { fake, messages, queries, permDirs }
return { fake, messages, queries, saves, replies, permDirs }
}

describe("recoveryDirs", () => {
Expand All @@ -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>()
Expand Down
Loading