mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-30 19:32:39 +08:00
fix(cli): prevent unsafe console worktree actions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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())),
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user