diff --git a/.changeset/route-worktree-permissions.md b/.changeset/route-worktree-permissions.md new file mode 100644 index 00000000000..b97fb570bcc --- /dev/null +++ b/.changeset/route-worktree-permissions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Route Agent Manager permission approvals to the worktree that created the request. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 19c6316a83d..a1738dff550 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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) @@ -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 diff --git a/packages/kilo-vscode/src/commands/toggle-auto-approve.ts b/packages/kilo-vscode/src/commands/toggle-auto-approve.ts index f553c430341..29e6ef891c6 100644 --- a/packages/kilo-vscode/src/commands/toggle-auto-approve.ts +++ b/packages/kilo-vscode/src/commands/toggle-auto-approve.ts @@ -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) }) diff --git a/packages/kilo-vscode/tests/unit/auto-approve.test.ts b/packages/kilo-vscode/tests/unit/auto-approve.test.ts index f890b906bb1..04a0f66fc7d 100644 --- a/packages/kilo-vscode/tests/unit/auto-approve.test.ts +++ b/packages/kilo-vscode/tests/unit/auto-approve.test.ts @@ -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[] }>() diff --git a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts index 68e5b4052e7..9f288b1d7d8 100644 --- a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts +++ b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts @@ -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[]>, queries: string[]) { +function permissionClient( + permsPerDir: Record[]>, + queries: string[], + saves: unknown[] = [], + replies: unknown[] = [], +) { return { permission: { list: async (args?: { directory?: string }) => { @@ -28,8 +34,14 @@ function permissionClient(permsPerDir: Record 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 function client( permsPerDir: Record[]>, 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() 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()