fix(cli): prevent unsafe console worktree actions

This commit is contained in:
Catriel Müller
2026-06-15 21:08:16 -03:00
parent e91eef2b38
commit 04d25fdf6d
11 changed files with 108 additions and 39 deletions
+3 -2
View File
@@ -29,6 +29,7 @@ import type {
VcsInfo,
Worktree,
WorktreeDiffItem,
WorktreeListResponse,
} from "@kilocode/sdk/v2/client"
export type Scope = "global" | "project"
@@ -54,7 +55,7 @@ export type ProjectConsoleSnapshot = {
project: ProjectItem
config: EffectiveConfig
vcs: VcsInfo
worktrees: string[]
worktrees: WorktreeListResponse
terminals: ProjectTerminalItem[]
}
@@ -447,7 +448,7 @@ export async function loadProjectConsole(input: ProjectConsoleQuery): Promise<Pr
])
const dirs = demand("Worktrees", worktrees)
const terminals = await Promise.all(
[query.dir, ...dirs].map((dir) => loadProjectTerminals({ url: input.url, dir }, dir)),
[query.dir, ...dirs.map((item) => item.directory)].map((dir) => loadProjectTerminals({ url: input.url, dir }, dir)),
)
return {
@@ -58,6 +58,7 @@ type Context = {
dir: string
label: string
kind: "local" | "worktree"
managed: boolean
}
type Editor = { kind: "create"; value: string } | { kind: "rename"; item: Context; value: string }
@@ -135,6 +136,7 @@ function refreshEvent(event: ProjectConsoleEvent) {
if (type.startsWith("permission.")) return true
if (type.startsWith("question.")) return true
if (type.startsWith("message.")) return true
if (type === "global.config.updated") return true
return false
}
@@ -179,8 +181,14 @@ export function ProjectConsoleRoute() {
const data = snap()
if (!data) return []
return [
{ id: "local", dir: data.project.worktree, label: "Local", kind: "local" },
...data.worktrees.map((dir) => ({ id: dir, dir, label: title(dir), kind: "worktree" as const })),
{ id: "local", dir: data.project.worktree, label: "Local", kind: "local", managed: false },
...data.worktrees.map((item) => ({
id: item.directory,
dir: item.directory,
label: title(item.directory),
kind: "worktree" as const,
managed: item.managed,
})),
]
})
const terminals = createMemo(() => {
@@ -499,8 +507,12 @@ export function ProjectConsoleRoute() {
setEditor({ kind: "rename", item, value: displayLabel(item) })
}
function canManage(item: Context | undefined) {
return item?.kind === "worktree" && item.managed
}
function removeWorktree(item: Context) {
if (!projectInput() || item.kind === "local") return
if (!projectInput() || !canManage(item)) return
setPending({ kind: "delete", item })
}
@@ -512,7 +524,7 @@ export function ProjectConsoleRoute() {
function resetSelected() {
const item = current()
if (!projectInput() || !item || item.kind === "local") return
if (!projectInput() || !canManage(item)) return
setPending({ kind: "reset", item })
}
@@ -653,7 +665,7 @@ export function ProjectConsoleRoute() {
const base = query()
const data = snap()
if (!base || !data) return
const dirs = new Set([data.project.worktree, ...data.worktrees])
const dirs = new Set([data.project.worktree, ...data.worktrees.map((item) => item.directory)])
const stop = subscribeProjectEvents({ url: base.url, dir: data.project.worktree }, (event) => {
if (event.directory !== "global" && !dirs.has(event.directory)) return
const id = eventSession(event)
@@ -768,19 +780,21 @@ export function ProjectConsoleRoute() {
>
<Icon name="edit" size="small" />
</button>
<button
type="button"
class="project-inline-action danger"
onClick={(event) => {
event.stopPropagation()
removeWorktree(item)
}}
disabled={!!saving()}
title={`Delete ${displayLabel(item)}`}
aria-label={`Delete ${displayLabel(item)}`}
>
<Icon name="trash" size="small" />
</button>
<Show when={item.managed}>
<button
type="button"
class="project-inline-action danger"
onClick={(event) => {
event.stopPropagation()
removeWorktree(item)
}}
disabled={!!saving()}
title={`Delete ${displayLabel(item)}`}
aria-label={`Delete ${displayLabel(item)}`}
>
<Icon name="trash" size="small" />
</button>
</Show>
</Show>
</div>
</div>
@@ -916,7 +930,7 @@ export function ProjectConsoleRoute() {
<code class="project-info-path" title={current()?.dir}>
{current()?.dir ?? snap()?.project.worktree ?? project()}
</code>
<Show when={current()?.kind === "worktree"}>
<Show when={canManage(current())}>
<div class="project-info-actions">
<Button variant="secondary" size="small" onClick={resetSelected} disabled={!!saving()}>
Reset
@@ -55,7 +55,7 @@ export const ToolListQuery = Schema.Struct({
model: ModelID,
})
const WorktreeList = Schema.Array(Schema.String)
const WorktreeList = Schema.Array(Schema.Struct({ directory: Schema.String, managed: Schema.Boolean }).annotate({ identifier: "WorktreeListItem" })) // kilocode_change
const WorktreeErrorName = Schema.Union([
Schema.Literal("WorktreeNotGitError"),
Schema.Literal("WorktreeNameGenerationFailedError"),
@@ -177,13 +177,13 @@ export const ExperimentalApi = HttpApi.make("experimental")
),
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
query: WorkspaceRoutingQuery,
success: described(WorktreeList, "List of worktree directories"),
success: described(WorktreeList, "List of worktrees"),
error: WorktreeApiError,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.list",
summary: "List worktrees",
description: "List all sandbox worktrees for the current project.",
description: "List all git worktrees for the current project and whether Kilo manages them.",
}),
),
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
@@ -114,8 +114,15 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
// kilocode_change start - discover Agent Manager and external git worktrees
const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () {
const ctx = yield* InstanceState.context
const managed = new Set((yield* project.sandboxes(ctx.project.id)).map((dir) => Filesystem.resolve(dir)))
return yield* mapWorktreeError(worktreeSvc.list()).pipe(
Effect.map((items) => items.map((item) => item.directory)),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
managed: managed.has(Filesystem.resolve(item.directory)),
})),
),
)
})
// kilocode_change end
+4 -1
View File
@@ -32,7 +32,10 @@ export function getChannelPath(flags: Pick<DatabaseFlags, "disableChannelDb"> =
if (["latest", "beta", "prod"].includes(InstallationChannel) || flags.disableChannelDb)
return path.join(Global.Path.data, "kilo.db") // kilocode_change
const safe = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
return path.join(Global.Path.data, `kilo-${safe}.db`) // kilocode_change
const next = path.join(Global.Path.data, `kilo-${safe}.db`) // kilocode_change
const prev = path.join(Global.Path.data, `opencode-${safe}.db`) // kilocode_change
if (!existsSync(next) && existsSync(prev)) return prev // kilocode_change
return next // kilocode_change
}
export const getPath = (flags?: Pick<DatabaseFlags, "disableChannelDb">) => {
@@ -56,12 +56,12 @@ describe("Kilo Console worktree listing", () => {
`${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
)
expect(response.status).toBe(200)
const worktrees = Schema.decodeUnknownSync(Schema.Array(Schema.String))(
yield* Effect.promise(() => response.json()),
)
const worktrees = Schema.decodeUnknownSync(
Schema.Array(Schema.Struct({ directory: Schema.String, managed: Schema.Boolean })),
)(yield* Effect.promise(() => response.json()))
expect(worktrees).toContain(directory)
expect(worktrees).not.toContain(test.directory)
expect(worktrees).toContainEqual({ directory, managed: false })
expect(worktrees.map((item) => item.directory)).not.toContain(test.directory)
}),
{ git: true },
)
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
import { Database } from "../../../src/storage/db"
import { tmpdir } from "../../fixture/fixture"
const custom = ["latest", "beta", "prod"].includes(InstallationChannel) ? test.skip : test
describe("kilo channel database paths", () => {
custom("falls back to the old opencode channel database", async () => {
await using dir = await tmpdir()
const data = Global.Path.data
;(Global.Path as { data: string }).data = dir.path
try {
const safe = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
const old = path.join(dir.path, `opencode-${safe}.db`)
await Bun.write(old, "")
expect(Database.getChannelPath({ disableChannelDb: false })).toBe(old)
} finally {
;(Global.Path as { data: string }).data = data
}
})
})
+1 -1
View File
@@ -13,7 +13,7 @@ describe("Database.getChannelPath", () => {
const flags = yield* RuntimeFlags.Service
const expected = ["latest", "beta", "prod"].includes(InstallationChannel)
? path.join(Global.Path.data, "kilo.db")
: path.join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
: path.join(Global.Path.data, `kilo-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) // kilocode_change
expect(Database.getChannelPath(flags)).toBe(expected)
}).pipe(Effect.provide(RuntimeFlags.layer())),
+1 -1
View File
@@ -1651,7 +1651,7 @@ export class Worktree extends HeyApiClient {
/**
* List worktrees
*
* List all sandbox worktrees for the current project.
* List all git worktrees for the current project and whether Kilo manages them.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
+7 -2
View File
@@ -1639,6 +1639,11 @@ export type ToolList = Array<ToolListItem>
export type ToolIds = Array<string>
export type WorktreeListItem = {
directory: string
managed: boolean
}
export type WorktreeError = {
name:
| "WorktreeNotGitError"
@@ -4776,9 +4781,9 @@ export type WorktreeListError = WorktreeListErrors[keyof WorktreeListErrors]
export type WorktreeListResponses = {
/**
* List of worktree directories
* List of worktrees
*/
200: Array<string>
200: Array<WorktreeListItem>
}
export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses]
+17 -4
View File
@@ -1062,15 +1062,15 @@
],
"responses": {
"200": {
"description": "List of worktree directories",
"description": "List of worktrees",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/components/schemas/WorktreeListItem"
},
"description": "List of worktree directories"
"description": "List of worktrees"
}
}
}
@@ -1086,7 +1086,7 @@
}
}
},
"description": "List all sandbox worktrees for the current project.",
"description": "List all git worktrees for the current project and whether Kilo manages them.",
"summary": "List worktrees",
"x-codeSamples": [
{
@@ -19318,6 +19318,19 @@
"type": "string"
}
},
"WorktreeListItem": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"managed": {
"type": "boolean"
}
},
"required": ["directory", "managed"],
"additionalProperties": false
},
"WorktreeError": {
"type": "object",
"properties": {