mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge branch 'main' into session/agent_9864f1dc-ca7c-4243-877b-14d089b66f92
This commit is contained in:
@@ -15,8 +15,9 @@ import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
import { Permission } from "@/permission"
|
||||
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||
import { Global } from "@/global"
|
||||
import path from "path"
|
||||
import { Global } from "@/global" // kilocode_change
|
||||
import { KilocodePaths } from "@/kilocode/paths" // kilocode_change
|
||||
import path from "path" // kilocode_change
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Skill } from "../skill"
|
||||
import { Effect, ServiceMap, Layer } from "effect"
|
||||
@@ -84,7 +85,14 @@ export namespace Agent {
|
||||
Effect.fn("Agent.state")(function* (ctx) {
|
||||
const cfg = yield* config.get()
|
||||
const skillDirs = yield* skill.dirs()
|
||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||
// kilocode_change start - include global config dirs so agents can read them without prompting
|
||||
const whitelistedDirs = [
|
||||
Truncate.GLOB,
|
||||
...skillDirs.map((dir) => path.join(dir, "*")),
|
||||
path.join(Global.Path.config, "*"),
|
||||
...KilocodePaths.globalDirs().map((dir) => path.join(dir, "*")),
|
||||
]
|
||||
// kilocode_change end
|
||||
|
||||
const baseDefaults = Permission.fromConfig({
|
||||
// kilocode_change: renamed from defaults
|
||||
|
||||
@@ -8,3 +8,4 @@ export const GlobalBus = new EventEmitter<{
|
||||
},
|
||||
]
|
||||
}>()
|
||||
GlobalBus.setMaxListeners(50) // kilocode_change — surface warning if SSE listeners accumulate
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// kilocode_change - new file
|
||||
import path from "path"
|
||||
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser"
|
||||
import { ConfigProtection } from "./permission/config-paths"
|
||||
import { ConfigMarkdown } from "@/config/markdown"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigPaths } from "@/config/paths"
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
export namespace ConfigValidation {
|
||||
const JSONC_EXT = new Set([".json", ".jsonc"])
|
||||
const COMMAND_DIRS = new Set(["command", "commands"])
|
||||
const AGENT_DIRS = new Set(["agent", "agents"])
|
||||
const MODE_DIRS = new Set(["mode", "modes"])
|
||||
|
||||
function label(filepath: string): string {
|
||||
const rel = path.isAbsolute(filepath)
|
||||
? filepath.replace(process.env.HOME || "~", "~")
|
||||
: filepath
|
||||
return rel
|
||||
}
|
||||
|
||||
async function jsonc(filepath: string): Promise<string> {
|
||||
const text = await ConfigPaths.readFile(filepath)
|
||||
if (text === undefined) return ""
|
||||
|
||||
const errors: ParseError[] = []
|
||||
const data = parse(text, errors, { allowTrailingComma: true })
|
||||
|
||||
if (errors.length > 0) {
|
||||
const lines = text.split("\n")
|
||||
const detail = errors
|
||||
.map((e) => {
|
||||
const before = text.substring(0, e.offset).split("\n")
|
||||
const line = before.length
|
||||
const col = before[before.length - 1].length + 1
|
||||
const src = lines[line - 1]
|
||||
const msg = `${printParseErrorCode(e.error)} at line ${line}, column ${col}`
|
||||
return src ? `${msg}\n Line ${line}: ${src}` : msg
|
||||
})
|
||||
.join("\n")
|
||||
return `\n\n<config_validation>\nERROR: Config file at ${label(filepath)} is not valid JSON(C)\n ${detail}\n</config_validation>`
|
||||
}
|
||||
|
||||
const result = Config.Info.safeParse(data)
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
|
||||
.join("\n")
|
||||
return `\n\n<config_validation>\nWARNING: Configuration is invalid at ${label(filepath)}\n${issues}\n</config_validation>`
|
||||
}
|
||||
|
||||
return `\n\n<config_validation>\nConfig file validated successfully.\n</config_validation>`
|
||||
}
|
||||
|
||||
async function markdown(filepath: string): Promise<string> {
|
||||
const dir = path.basename(path.dirname(filepath))
|
||||
|
||||
// Determine schema from parent directory
|
||||
const schema = COMMAND_DIRS.has(dir)
|
||||
? "command"
|
||||
: AGENT_DIRS.has(dir) || MODE_DIRS.has(dir)
|
||||
? "agent"
|
||||
: undefined
|
||||
if (!schema) return ""
|
||||
|
||||
let md: Awaited<ReturnType<typeof ConfigMarkdown.parse>>
|
||||
try {
|
||||
md = await ConfigMarkdown.parse(filepath)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
const msg = ConfigMarkdown.FrontmatterError.isInstance(e)
|
||||
? e.data.message
|
||||
: `Failed to parse frontmatter: ${e instanceof Error ? e.message : String(e)}`
|
||||
return `\n\n<config_validation>\nERROR: ${label(filepath)}\n ${msg}\n</config_validation>`
|
||||
}
|
||||
|
||||
const config = schema === "command"
|
||||
? { ...md.data, template: md.content.trim() }
|
||||
: { ...md.data, prompt: md.content.trim() }
|
||||
|
||||
const zod = schema === "command" ? Config.Command : Config.Agent
|
||||
const result = zod.safeParse(config)
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
|
||||
.join("\n")
|
||||
return `\n\n<config_validation>\nWARNING: Configuration is invalid at ${label(filepath)}\n${issues}\n</config_validation>`
|
||||
}
|
||||
|
||||
return `\n\n<config_validation>\nConfig file validated successfully.\n</config_validation>`
|
||||
}
|
||||
|
||||
function isConfig(filepath: string): boolean {
|
||||
if (!path.isAbsolute(filepath)) return ConfigProtection.isRelative(filepath)
|
||||
// Global config dirs (e.g. ~/.config/kilo/)
|
||||
if (ConfigProtection.isAbsolute(filepath)) return true
|
||||
// Project-local config (e.g. /project/.kilo/command/foo.md)
|
||||
try {
|
||||
const rel = path.relative(Instance.worktree, filepath)
|
||||
if (!rel.startsWith("..")) return ConfigProtection.isRelative(rel)
|
||||
} catch {
|
||||
// Not in an Instance context — skip project-relative check
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function existing(): Promise<string> {
|
||||
try {
|
||||
const warns = await Config.warnings()
|
||||
if (!warns || warns.length === 0) return ""
|
||||
const items = warns
|
||||
.map((w: Config.Warning) => ` ${label(w.path)}: ${w.message}`)
|
||||
.join("\n")
|
||||
return `Pre-existing config issues (from session start):\n${items}\n\n`
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a file if it's a config file. Returns formatted output to append
|
||||
* to tool results, or empty string for non-config files.
|
||||
*/
|
||||
export async function check(filepath: string): Promise<string> {
|
||||
if (!isConfig(filepath)) return ""
|
||||
|
||||
const ext = path.extname(filepath).toLowerCase()
|
||||
|
||||
// Skip AGENTS.md and other root .md files not in a recognized config subdir
|
||||
if (ext === ".md") {
|
||||
const dir = path.basename(path.dirname(filepath))
|
||||
if (!COMMAND_DIRS.has(dir) && !AGENT_DIRS.has(dir) && !MODE_DIRS.has(dir)) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = await existing()
|
||||
|
||||
const validation = JSONC_EXT.has(ext)
|
||||
? await jsonc(filepath)
|
||||
: ext === ".md"
|
||||
? await markdown(filepath)
|
||||
: ""
|
||||
|
||||
if (!validation) return ""
|
||||
|
||||
if (prefix) {
|
||||
// Replace the opening tag content with prefixed version
|
||||
return validation.replace(
|
||||
"<config_validation>\n",
|
||||
`<config_validation>\n${prefix}Post-edit validation of ${label(filepath)}:\n`,
|
||||
)
|
||||
}
|
||||
|
||||
return validation
|
||||
}
|
||||
}
|
||||
@@ -80,18 +80,21 @@ export namespace ConfigProtection {
|
||||
|
||||
/**
|
||||
* Determine if a permission request targets config files.
|
||||
* Checks `edit` and `external_directory` permissions — read access is not restricted.
|
||||
* Gates `edit` permissions and bash-originated `external_directory` requests.
|
||||
* File-tool reads are not restricted.
|
||||
*/
|
||||
export function isRequest(request: {
|
||||
permission: string
|
||||
patterns: string[]
|
||||
metadata?: Record<string, any>
|
||||
}): boolean {
|
||||
// external_directory patterns are absolute globs like "/Users/alex/.config/kilo/*"
|
||||
if (request.permission === "external_directory") {
|
||||
// File tools include metadata.filepath. They may read global config
|
||||
// without prompting, but edits are still protected separately via `edit`.
|
||||
if (request.metadata?.filepath) return false
|
||||
for (const pattern of request.patterns) {
|
||||
const dir = pattern.replace(/\/\*$/, "")
|
||||
if (isAbsolute(dir)) return true
|
||||
if (path.isAbsolute(dir) && isAbsolute(dir)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// kilocode_change - new file
|
||||
import { $ } from "bun"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import z from "zod"
|
||||
@@ -245,7 +244,7 @@ export namespace WorktreeDiff {
|
||||
const additions = meta.status === "added" && meta.additions === 0 && !meta.tracked ? lines(after) : meta.additions
|
||||
return {
|
||||
file: meta.file,
|
||||
patch: createTwoFilesPatch(meta.file, meta.file, before, after),
|
||||
patch: "",
|
||||
before,
|
||||
after,
|
||||
additions,
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated by build.ts - do not edit
|
||||
export declare const snapshot: Record<string, unknown>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,14 +70,27 @@ export const EventRoutes = () =>
|
||||
|
||||
stream.onAbort(stop)
|
||||
|
||||
// kilocode_change start
|
||||
// On Windows, stream.onAbort() may never fire after a client disconnects
|
||||
// (delayed TCP RST detection via IOCP). Without this try/catch, the
|
||||
// GlobalBus listener, heartbeat interval, and AsyncQueue stay alive
|
||||
// indefinitely for each dead connection — leaking memory on every
|
||||
// SSE reconnect. Catching write errors lets us clean up eagerly.
|
||||
try {
|
||||
for await (const data of q) {
|
||||
if (data === null) return
|
||||
await stream.writeSSE({ data })
|
||||
try {
|
||||
await stream.writeSSE({ data })
|
||||
} catch {
|
||||
log.info("event write failed, cleaning up dead stream")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
// kilocode_change end
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -56,14 +56,27 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>
|
||||
|
||||
stream.onAbort(stop)
|
||||
|
||||
// kilocode_change start
|
||||
// On Windows, stream.onAbort() may never fire after a client disconnects
|
||||
// (delayed TCP RST detection via IOCP). Without this try/catch, the
|
||||
// GlobalBus listener, heartbeat interval, and AsyncQueue stay alive
|
||||
// indefinitely for each dead connection — leaking memory on every
|
||||
// SSE reconnect. Catching write errors lets us clean up eagerly.
|
||||
try {
|
||||
for await (const data of q) {
|
||||
if (data === null) return
|
||||
await stream.writeSSE({ data })
|
||||
try {
|
||||
await stream.writeSSE({ data })
|
||||
} catch {
|
||||
log.info("global event write failed, cleaning up dead stream")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
// kilocode_change end
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,16 @@ export namespace Skill {
|
||||
directory: string,
|
||||
worktree: string,
|
||||
) {
|
||||
// kilocode_change start - seed built-in skills before discovery so user skills can override
|
||||
for (const skill of BUILTIN_SKILLS) {
|
||||
state.skills[skill.name] = {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
location: BUILTIN_LOCATION,
|
||||
content: skill.content,
|
||||
}
|
||||
}
|
||||
// kilocode_change end
|
||||
if (!Flag.KILO_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Filesystem } from "../util/filesystem"
|
||||
import DESCRIPTION from "./apply_patch.txt"
|
||||
import { File } from "../file"
|
||||
import { filterDiagnostics } from "./diagnostics" // kilocode_change
|
||||
import { ConfigValidation } from "../kilocode/config-validation" // kilocode_change
|
||||
import { Format } from "../format"
|
||||
|
||||
const PatchParams = z.object({
|
||||
@@ -271,6 +272,10 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||
const changedPaths = fileChanges
|
||||
.filter((c) => c.type !== "delete")
|
||||
.map((c) => Filesystem.normalizePath(c.movePath ?? c.filePath))
|
||||
for (const changed of fileChanges) {
|
||||
if (changed.type === "delete") continue
|
||||
output += await ConfigValidation.check(changed.movePath ?? changed.filePath)
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
return {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Instance } from "../project/instance"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { filterDiagnostics } from "./diagnostics" // kilocode_change
|
||||
import { ConfigValidation } from "../kilocode/config-validation" // kilocode_change
|
||||
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
const MAX_DIFF_CONTENT = 500_000 // kilocode_change
|
||||
@@ -180,6 +181,7 @@ export const EditTool = Tool.define("edit", {
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
output += await ConfigValidation.check(filePath) // kilocode_change
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
|
||||
@@ -7,6 +7,24 @@ import { Ripgrep } from "../file/ripgrep"
|
||||
import { Instance } from "../project/instance"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
|
||||
// kilocode_change start — support absolute glob patterns (e.g. ~/.config/kilo/command/*.md)
|
||||
function normalize(p: string) {
|
||||
return p.replaceAll("\\", "/")
|
||||
}
|
||||
|
||||
function split(pattern: string) {
|
||||
const normalized = normalize(pattern)
|
||||
if (!path.isAbsolute(normalized)) return
|
||||
const index = normalized.search(/[*?{[]/)
|
||||
if (index === -1) return { dir: normalized, pattern: "*" }
|
||||
const slice = normalized.slice(0, index)
|
||||
const cut = slice.lastIndexOf("/")
|
||||
const dir = cut > 0 ? slice.slice(0, cut) : "/"
|
||||
const next = normalized.slice(cut + 1)
|
||||
return { dir, pattern: next || "*" }
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
export const GlobTool = Tool.define("glob", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
@@ -19,6 +37,7 @@ export const GlobTool = Tool.define("glob", {
|
||||
),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const absolute = split(params.pattern) // kilocode_change
|
||||
await ctx.ask({
|
||||
permission: "glob",
|
||||
patterns: [params.pattern],
|
||||
@@ -29,7 +48,7 @@ export const GlobTool = Tool.define("glob", {
|
||||
},
|
||||
})
|
||||
|
||||
let search = params.path ?? Instance.directory
|
||||
let search = absolute?.dir ?? params.path ?? Instance.directory // kilocode_change
|
||||
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
||||
await assertExternalDirectory(ctx, search, { kind: "directory" })
|
||||
|
||||
@@ -38,7 +57,7 @@ export const GlobTool = Tool.define("glob", {
|
||||
let truncated = false
|
||||
for await (const file of Ripgrep.files({
|
||||
cwd: search,
|
||||
glob: [params.pattern],
|
||||
glob: [absolute?.pattern ?? params.pattern], // kilocode_change
|
||||
signal: ctx.abort,
|
||||
})) {
|
||||
if (files.length >= limit) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Instance } from "../project/instance"
|
||||
import { trimDiff, buildFileDiff } from "./edit" // kilocode_change
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { filterDiagnostics } from "./diagnostics" // kilocode_change
|
||||
import { ConfigValidation } from "../kilocode/config-validation" // kilocode_change
|
||||
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
|
||||
@@ -73,6 +74,7 @@ export const WriteTool = Tool.define("write", {
|
||||
projectDiagnosticsCount++
|
||||
output += `\n\nLSP errors detected in other files:\n<diagnostics file="${file}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
output += await ConfigValidation.check(filepath) // kilocode_change
|
||||
|
||||
return {
|
||||
title: path.relative(Instance.worktree, filepath),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// kilocode_change - new file
|
||||
import { afterEach, test, expect } from "bun:test"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Global } from "../../src/global"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("code agent allows global config directory reads by default", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const code = await Agent.get("code")
|
||||
expect(code).toBeDefined()
|
||||
expect(Permission.evaluate("external_directory", `${Global.Path.config}/*`, code!.permission).action).toBe(
|
||||
"allow",
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { BUILTIN_SKILLS } from "../../src/kilocode/skills/builtin"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("built-in skills are present in empty project", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
for (const builtin of BUILTIN_SKILLS) {
|
||||
const found = skills.find((s) => s.name === builtin.name)
|
||||
expect(found).toBeDefined()
|
||||
expect(found!.location).toBe(Skill.BUILTIN_LOCATION)
|
||||
expect(found!.description).toBe(builtin.description)
|
||||
expect(found!.content.length).toBeGreaterThan(0)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("built-in skill has correct metadata", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skill = await Skill.get("kilo-config")
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill!.name).toBe("kilo-config")
|
||||
expect(skill!.location).toBe(Skill.BUILTIN_LOCATION)
|
||||
expect(skill!.content).toContain("kilo")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("user skill overrides built-in with same name", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".kilo", "skill", "kilo-config")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: kilo-config
|
||||
description: User override of kilo-config.
|
||||
---
|
||||
|
||||
# Custom kilo-config
|
||||
|
||||
User-provided content.
|
||||
`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skill = await Skill.get("kilo-config")
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill!.description).toBe("User override of kilo-config.")
|
||||
expect(skill!.location).not.toBe(Skill.BUILTIN_LOCATION)
|
||||
expect(skill!.location).toContain(path.join("skill", "kilo-config", "SKILL.md"))
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
// kilocode_change - new file
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { ConfigValidation } from "../../src/kilocode/config-validation"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
describe("ConfigValidation.check", () => {
|
||||
test("returns empty string for non-config files", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "src", "index.ts")
|
||||
await Filesystem.write(filepath, "export const x = 1")
|
||||
|
||||
const result = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
})
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
test("validates valid JSONC config", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "kilo.json")
|
||||
await Filesystem.write(filepath, JSON.stringify({ model: "anthropic/claude-sonnet-4-20250514" }))
|
||||
|
||||
const result = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
})
|
||||
expect(result).toContain("config_validation")
|
||||
expect(result).toContain("validated successfully")
|
||||
})
|
||||
|
||||
test("reports JSONC syntax errors", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "kilo.json")
|
||||
await Filesystem.write(filepath, '{ "model": "test/model" "extra": true }')
|
||||
|
||||
const result = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
})
|
||||
expect(result).toContain("config_validation")
|
||||
expect(result).toContain("ERROR")
|
||||
expect(result).toContain("not valid JSON(C)")
|
||||
})
|
||||
|
||||
test("reports schema validation errors for unknown fields", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "kilo.json")
|
||||
// Config.Info uses .strict() so unknown fields produce errors
|
||||
await Filesystem.write(filepath, JSON.stringify({ notAField: true }))
|
||||
|
||||
const result = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
})
|
||||
expect(result).toContain("config_validation")
|
||||
expect(result).toContain("WARNING")
|
||||
expect(result).toContain("invalid")
|
||||
})
|
||||
|
||||
test("validates valid markdown command", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, ".kilo", "command", "test-cmd.md")
|
||||
await Filesystem.write(
|
||||
filepath,
|
||||
`---
|
||||
description: A test command
|
||||
---
|
||||
Do something useful`,
|
||||
)
|
||||
|
||||
const result = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
})
|
||||
expect(result).toContain("config_validation")
|
||||
expect(result).toContain("validated successfully")
|
||||
})
|
||||
|
||||
test("reports schema error for command with invalid field types", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, ".kilo", "command", "bad.md")
|
||||
// agent expects string but gets number — schema validation fails
|
||||
await Filesystem.write(
|
||||
filepath,
|
||||
`---
|
||||
agent: 123
|
||||
subtask: "not-a-boolean"
|
||||
---
|
||||
Do something`,
|
||||
)
|
||||
|
||||
const result = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
})
|
||||
expect(result).toContain("config_validation")
|
||||
expect(result).toContain("WARNING")
|
||||
expect(result).toContain("invalid")
|
||||
})
|
||||
|
||||
test("validates valid markdown agent", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, ".kilo", "agent", "helper.md")
|
||||
await Filesystem.write(
|
||||
filepath,
|
||||
`---
|
||||
model: anthropic/claude-sonnet-4-20250514
|
||||
description: A helper agent
|
||||
---
|
||||
You are a helpful agent.`,
|
||||
)
|
||||
|
||||
const result = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
})
|
||||
expect(result).toContain("config_validation")
|
||||
expect(result).toContain("validated successfully")
|
||||
})
|
||||
|
||||
test("skips AGENTS.md (root md file not in config subdir)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "AGENTS.md")
|
||||
await Filesystem.write(filepath, "# Project agents")
|
||||
|
||||
const result = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
})
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
test("skips plan files (excluded subdir)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, ".kilo", "plans", "plan.md")
|
||||
await Filesystem.write(filepath, "# Plan")
|
||||
|
||||
const result = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => ConfigValidation.check(filepath),
|
||||
})
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
test("includes pre-existing warnings when present", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
// Create a broken agent config that produces a warning at session start
|
||||
await Filesystem.write(
|
||||
path.join(dir, ".kilo", "agent", "broken.md"),
|
||||
`---
|
||||
mode: "banana"
|
||||
---
|
||||
Broken agent`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const filepath = path.join(tmp.path, "kilo.json")
|
||||
await Filesystem.write(filepath, JSON.stringify({ model: "anthropic/claude-sonnet-4-20250514" }))
|
||||
|
||||
const result = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Force config load to populate warnings
|
||||
await Config.get()
|
||||
return ConfigValidation.check(filepath)
|
||||
},
|
||||
})
|
||||
expect(result).toContain("Pre-existing config issues")
|
||||
expect(result).toContain("broken.md")
|
||||
expect(result).toContain("Post-edit validation")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
// kilocode_change - new file
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigProtection } from "../../../src/kilocode/permission/config-paths"
|
||||
import { Global } from "../../../src/global"
|
||||
import { KilocodePaths } from "../../../src/kilocode/paths"
|
||||
|
||||
describe("ConfigProtection.isRequest", () => {
|
||||
const config = path.resolve(Global.Path.config)
|
||||
const legacy = KilocodePaths.globalDirs().map((d) => path.resolve(d))
|
||||
|
||||
// --- external_directory: bash-originated (empty metadata) ---
|
||||
|
||||
test("returns true for bash external_directory targeting global config", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "external_directory",
|
||||
patterns: [config + "/*"],
|
||||
metadata: {},
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for bash external_directory targeting skill dir", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "external_directory",
|
||||
patterns: [path.join(config, "skills", "my-skill") + "/*"],
|
||||
metadata: {},
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for bash external_directory targeting legacy global dir", () => {
|
||||
for (const dir of legacy) {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "external_directory",
|
||||
patterns: [dir + "/*"],
|
||||
metadata: {},
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
// --- external_directory: file-tool-originated (has metadata.filepath) ---
|
||||
|
||||
test("returns false for file-tool external_directory targeting global config", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "external_directory",
|
||||
patterns: [config + "/*"],
|
||||
metadata: { filepath: path.join(config, "kilo.json"), parentDir: config },
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for file-tool external_directory targeting global config root dir", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "external_directory",
|
||||
patterns: [config + "/*"],
|
||||
metadata: { filepath: config, parentDir: config },
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for file-tool external_directory targeting readable global command dir", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "external_directory",
|
||||
patterns: [path.join(config, "command") + "/*"],
|
||||
metadata: { filepath: path.join(config, "command", "foo.md"), parentDir: path.join(config, "command") },
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for file-tool external_directory targeting readable global skill dir", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "external_directory",
|
||||
patterns: [path.join(config, "skills") + "/*"],
|
||||
metadata: {
|
||||
filepath: path.join(config, "skills", "my-skill", "SKILL.md"),
|
||||
parentDir: path.join(config, "skills"),
|
||||
},
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
// --- external_directory: non-config dirs ---
|
||||
|
||||
test("returns false for bash external_directory targeting non-config dir", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "external_directory",
|
||||
patterns: ["/tmp/some-project/*"],
|
||||
metadata: {},
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
// --- edit permission ---
|
||||
|
||||
test("returns true for edit targeting global config file via metadata.filepath", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "edit",
|
||||
patterns: [],
|
||||
metadata: { filepath: path.join(config, "config.json") },
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for edit targeting skill file via metadata.filepath", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "edit",
|
||||
patterns: [],
|
||||
metadata: { filepath: path.join(config, "skills", "my-skill", "SKILL.md") },
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for edit targeting legacy global dir via metadata.filepath", () => {
|
||||
for (const dir of legacy) {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "edit",
|
||||
patterns: [],
|
||||
metadata: { filepath: path.join(dir, "config.json") },
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("returns true for edit targeting relative config path via patterns", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "edit",
|
||||
patterns: [".kilo/command/foo.md"],
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for edit targeting excluded subdir (plans)", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "edit",
|
||||
patterns: [".kilo/plans/plan.md"],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for read permission", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "read",
|
||||
patterns: [".kilo/config.json"],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for bash permission", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "bash",
|
||||
patterns: ["cat " + path.join(config, "config.json")],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true for edit targeting root config files", () => {
|
||||
for (const file of ["kilo.json", "kilo.jsonc", "AGENTS.md"]) {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "edit",
|
||||
patterns: [file],
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("returns false for edit targeting non-config files", () => {
|
||||
const result = ConfigProtection.isRequest({
|
||||
permission: "edit",
|
||||
patterns: ["src/index.ts"],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { WorktreeDiff } from "../../src/kilocode/review/worktree-diff"
|
||||
import path from "path"
|
||||
|
||||
/**
|
||||
@@ -145,6 +146,19 @@ describe("worktree diff git commands", () => {
|
||||
expect(allFiles).toContain("new-file.py")
|
||||
})
|
||||
|
||||
test("worktree detail does not include unused patch content", async () => {
|
||||
await using tmp = await setupRepo()
|
||||
const dir = tmp.path
|
||||
|
||||
await Bun.write(path.join(dir, "existing.txt"), "hello\nmodified\n")
|
||||
|
||||
const detail = await WorktreeDiff.detail({ dir, base: "HEAD", file: "existing.txt" })
|
||||
|
||||
expect(detail?.patch).toBe("")
|
||||
expect(detail?.before).toBe("hello\n")
|
||||
expect(detail?.after).toBe("hello\nmodified\n")
|
||||
})
|
||||
|
||||
test("worktree scenario: branch with no new commits, only untracked files", async () => {
|
||||
// This is the exact scenario from the screenshot:
|
||||
// - Worktree created from main
|
||||
|
||||
@@ -51,7 +51,7 @@ Instructions here.
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
const skills = (await Skill.all()).filter((s) => s.location !== Skill.BUILTIN_LOCATION) // kilocode_change
|
||||
expect(skills.length).toBe(1)
|
||||
const testSkill = skills.find((s) => s.name === "test-skill")
|
||||
expect(testSkill).toBeDefined()
|
||||
@@ -131,7 +131,7 @@ description: Second test skill.
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
const skills = (await Skill.all()).filter((s) => s.location !== Skill.BUILTIN_LOCATION) // kilocode_change
|
||||
expect(skills.length).toBe(2)
|
||||
expect(skills.find((s) => s.name === "skill-one")).toBeDefined()
|
||||
expect(skills.find((s) => s.name === "skill-two")).toBeDefined()
|
||||
@@ -157,7 +157,7 @@ Just some content without YAML frontmatter.
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
const skills = (await Skill.all()).filter((s) => s.location !== Skill.BUILTIN_LOCATION) // kilocode_change
|
||||
expect(skills).toEqual([])
|
||||
},
|
||||
})
|
||||
@@ -184,7 +184,7 @@ description: A skill in the .claude/skills directory.
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
const skills = (await Skill.all()).filter((s) => s.location !== Skill.BUILTIN_LOCATION) // kilocode_change
|
||||
expect(skills.length).toBe(1)
|
||||
const claudeSkill = skills.find((s) => s.name === "claude-skill")
|
||||
expect(claudeSkill).toBeDefined()
|
||||
@@ -204,7 +204,7 @@ test("discovers global skills from ~/.claude/skills/ directory", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
const skills = (await Skill.all()).filter((s) => s.location !== Skill.BUILTIN_LOCATION) // kilocode_change // kilocode_change
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("global-test-skill")
|
||||
expect(skills[0].description).toBe("A global skill from ~/.claude/skills for testing.")
|
||||
@@ -222,7 +222,7 @@ test("returns empty array when no skills exist", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
const skills = (await Skill.all()).filter((s) => s.location !== Skill.BUILTIN_LOCATION) // kilocode_change
|
||||
expect(skills).toEqual([])
|
||||
},
|
||||
})
|
||||
@@ -249,7 +249,7 @@ description: A skill in the .agents/skills directory.
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
const skills = (await Skill.all()).filter((s) => s.location !== Skill.BUILTIN_LOCATION) // kilocode_change
|
||||
expect(skills.length).toBe(1)
|
||||
const agentSkill = skills.find((s) => s.name === "agent-skill")
|
||||
expect(agentSkill).toBeDefined()
|
||||
@@ -283,7 +283,7 @@ This skill is loaded from the global home directory.
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
const skills = (await Skill.all()).filter((s) => s.location !== Skill.BUILTIN_LOCATION) // kilocode_change // kilocode_change
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("global-agent-skill")
|
||||
expect(skills[0].description).toBe("A global skill from ~/.agents/skills for testing.")
|
||||
@@ -327,7 +327,7 @@ description: A skill in the .agents/skills directory.
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
const skills = (await Skill.all()).filter((s) => s.location !== Skill.BUILTIN_LOCATION) // kilocode_change
|
||||
expect(skills.length).toBe(2)
|
||||
expect(skills.find((s) => s.name === "claude-skill")).toBeDefined()
|
||||
expect(skills.find((s) => s.name === "agent-skill")).toBeDefined()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// kilocode_change - new file
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { GlobTool } from "../../src/tool/glob"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("tool.glob", () => {
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("test"),
|
||||
messageID: MessageID.make("test"),
|
||||
callID: "",
|
||||
agent: "code",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
test("supports absolute glob patterns outside the project", async () => {
|
||||
await using outer = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "one.md"), "one")
|
||||
await Bun.write(path.join(dir, "two.md"), "two")
|
||||
await Bun.write(path.join(dir, "three.txt"), "three")
|
||||
},
|
||||
})
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const glob = await GlobTool.init()
|
||||
const result = await glob.execute(
|
||||
{
|
||||
pattern: path.join(outer.path, "*.md"),
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.output).toContain(path.join(outer.path, "one.md"))
|
||||
expect(result.output).toContain(path.join(outer.path, "two.md"))
|
||||
expect(result.output).not.toContain(path.join(outer.path, "three.txt"))
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user