mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge pull request #12566 from Kilo-Org/abalone-bactrosaurus
feat(vscode): multi-project Agent Manager
This commit is contained in:
@@ -54,6 +54,7 @@ export namespace KilocodeBootstrap {
|
||||
yield* bus.subscribeCallback(MemoryEvents.Updated, (evt) =>
|
||||
KiloToolRegistry.invalidateMemoryEnabled(evt.properties.directory),
|
||||
)
|
||||
// Session export bootstrap.
|
||||
yield* Effect.gen(function* () {
|
||||
if (!SessionExport.enabled) return
|
||||
const anon = yield* EffectBridge.fromPromise(() =>
|
||||
@@ -78,14 +79,16 @@ export namespace KilocodeBootstrap {
|
||||
Effect.sync(() => log.warn("session export bootstrap failed", { err: Cause.squash(cause) })),
|
||||
),
|
||||
)
|
||||
yield* EffectBridge.fromPromise(() =>
|
||||
import("@/kilocode/indexing").then((mod) => mod.KiloIndexing.init()),
|
||||
).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => log.warn("indexing bootstrap failed", { err: Cause.squash(cause) })),
|
||||
),
|
||||
Effect.forkDetach,
|
||||
)
|
||||
if (process.env["KILO_PLATFORM"] !== "vscode") {
|
||||
yield* EffectBridge.fromPromise(() =>
|
||||
import("@/kilocode/indexing").then((mod) => mod.KiloIndexing.init()),
|
||||
).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => log.warn("indexing bootstrap failed", { err: Cause.squash(cause) })),
|
||||
),
|
||||
Effect.forkDetach,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ init })
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import path from "path"
|
||||
import { existsSync } from "fs"
|
||||
import { access, realpath } from "fs/promises"
|
||||
import { constants } from "fs"
|
||||
import { createHash } from "crypto"
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -54,16 +57,21 @@ export namespace KilocodeConfigOverlay {
|
||||
global: z.custom<Config.Info>(Schema.is(Config.Info)),
|
||||
project: z.custom<Config.Info>(Schema.is(Config.Info)),
|
||||
sources: z.array(KilocodeConfigSources.Source),
|
||||
targets: z.object({
|
||||
global: z.string().optional(),
|
||||
project: z.string().optional(),
|
||||
active: z.string().optional(),
|
||||
}),
|
||||
targets: z.object({ global: z.custom<Target>(), project: z.custom<Target>(), active: z.custom<Target>() }),
|
||||
fields: z.record(z.string(), Resolved),
|
||||
collections: z.record(z.string(), z.array(Resolved)),
|
||||
})
|
||||
export type Result = z.infer<typeof Result>
|
||||
|
||||
export type Target = {
|
||||
scope: Scope
|
||||
path: string
|
||||
revision: string
|
||||
exists: boolean
|
||||
writable: boolean
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Input = {
|
||||
directory: string
|
||||
worktree?: string
|
||||
@@ -144,16 +152,54 @@ export namespace KilocodeConfigOverlay {
|
||||
return candidates.find((file) => existsSync(file)) ?? candidates[0]
|
||||
}
|
||||
|
||||
export async function target(input: { scope: Scope; directory: string; worktree?: string }): Promise<Target> {
|
||||
const file = input.scope === "global" ? globalTarget() : await projectTarget(input)
|
||||
const root =
|
||||
input.scope === "global"
|
||||
? Global.Path.config
|
||||
: input.worktree && input.worktree !== "/"
|
||||
? input.worktree
|
||||
: input.directory
|
||||
const [canonical, boundary] = await Promise.all([canonicalize(file), canonicalize(root)])
|
||||
const exists = await Bun.file(file).exists()
|
||||
const bytes = exists ? await Bun.file(file).text() : ""
|
||||
const relative = path.relative(boundary, canonical)
|
||||
const inside = relative === "" || (!path.isAbsolute(relative) && relative.split(path.sep)[0] !== "..")
|
||||
const raw = exists ? ConfigParse.jsonc(bytes, canonical) : {}
|
||||
return {
|
||||
scope: input.scope,
|
||||
path: canonical,
|
||||
revision: revision(canonical, exists, bytes),
|
||||
exists,
|
||||
writable: inside && (await canWrite(exists ? canonical : path.dirname(canonical))),
|
||||
raw: isRecord(raw) ? raw : {},
|
||||
}
|
||||
}
|
||||
|
||||
export function revision(file: string, exists: boolean, bytes: string) {
|
||||
return createHash("sha256")
|
||||
.update(file)
|
||||
.update("\0")
|
||||
.update(exists ? "exists" : "missing")
|
||||
.update("\0")
|
||||
.update(bytes)
|
||||
.digest("hex")
|
||||
}
|
||||
|
||||
export async function resolve(input: Input): Promise<Result> {
|
||||
// kilocode_change start - project agents untrusted, {file:} confined to the project root; global agents trusted
|
||||
const root = input.worktree && input.worktree !== "/" ? input.worktree : input.directory
|
||||
const local = await withAgents(await project(input), await projectDirs(input), false, root)
|
||||
const global = await withAgents(input.global, globalDirs(), true)
|
||||
// kilocode_change end
|
||||
const [globalTarget, projectTarget] = await Promise.all([
|
||||
target({ ...input, scope: "global" }),
|
||||
target({ ...input, scope: "project" }),
|
||||
])
|
||||
const targets = {
|
||||
global: globalTarget(),
|
||||
project: await projectTarget(input),
|
||||
active: input.scope === "global" ? globalTarget() : await projectTarget(input),
|
||||
global: globalTarget,
|
||||
project: projectTarget,
|
||||
active: input.scope === "global" ? globalTarget : projectTarget,
|
||||
}
|
||||
return {
|
||||
scope: input.scope,
|
||||
@@ -367,4 +413,24 @@ export namespace KilocodeConfigOverlay {
|
||||
if (isRecord(input)) return input
|
||||
return {}
|
||||
}
|
||||
|
||||
async function canonicalize(file: string): Promise<string> {
|
||||
const resolved = path.resolve(file)
|
||||
const found = await realpath(resolved).catch(() => undefined)
|
||||
if (found) return path.normalize(found)
|
||||
const parent = path.dirname(resolved)
|
||||
if (parent === resolved) return resolved
|
||||
return path.join(await canonicalize(parent), path.basename(resolved))
|
||||
}
|
||||
|
||||
async function canWrite(file: string): Promise<boolean> {
|
||||
return access(file, constants.W_OK).then(
|
||||
() => true,
|
||||
async () => {
|
||||
const parent = path.dirname(file)
|
||||
if (parent === file) return false
|
||||
return canWrite(parent)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser"
|
||||
import { mkdir, stat } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigParse } from "@/config/parse"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { KilocodeConfigOverlay } from "./overlay"
|
||||
|
||||
export namespace KilocodeConfigWriter {
|
||||
export type Conflict = {
|
||||
ok: false
|
||||
code: "target-changed" | "revision-conflict" | "target-not-writable"
|
||||
message: string
|
||||
target: KilocodeConfigOverlay.Target
|
||||
}
|
||||
|
||||
export type Result = { ok: true; target: KilocodeConfigOverlay.Target } | Conflict
|
||||
|
||||
export async function write(input: {
|
||||
directory: string
|
||||
worktree?: string
|
||||
scope: KilocodeConfigOverlay.Scope
|
||||
expected?: { path: string; revision: string }
|
||||
set?: Record<string, unknown>
|
||||
unset?: string[][]
|
||||
write?: typeof Filesystem.write
|
||||
beforeWrite?: () => Promise<void>
|
||||
}): Promise<Result> {
|
||||
const target = await KilocodeConfigOverlay.target(input)
|
||||
const expected = input.expected
|
||||
if (expected && target.path !== expected.path) {
|
||||
return { ok: false, code: "target-changed", message: "The authoritative config target changed.", target }
|
||||
}
|
||||
if (expected && target.revision !== expected.revision) {
|
||||
return { ok: false, code: "revision-conflict", message: "The config file changed since it was read.", target }
|
||||
}
|
||||
if (!target.writable) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "target-not-writable",
|
||||
message: "The config target is outside its allowed root or is not writable.",
|
||||
target,
|
||||
}
|
||||
}
|
||||
|
||||
const patch = KilocodeConfigOverlay.patch({ scope: input.scope, set: input.set, unset: input.unset })
|
||||
if (Object.keys(patch).length === 0) return { ok: true, target }
|
||||
await mkdir(path.dirname(target.path), { recursive: true })
|
||||
await input.beforeWrite?.()
|
||||
const checked = await KilocodeConfigOverlay.target(input)
|
||||
if ((expected && checked.path !== expected.path) || !checked.writable) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "target-not-writable",
|
||||
message: "The config target changed or escaped its allowed root.",
|
||||
target: checked,
|
||||
}
|
||||
}
|
||||
const before = checked.exists ? await Bun.file(checked.path).text() : "{}"
|
||||
if (
|
||||
expected &&
|
||||
KilocodeConfigOverlay.revision(checked.path, checked.exists, checked.exists ? before : "") !== expected.revision
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "revision-conflict",
|
||||
message: "The config file changed since it was read.",
|
||||
target: checked,
|
||||
}
|
||||
}
|
||||
const updated = patchJsonc(before, patch)
|
||||
ConfigParse.schema(Config.Info, ConfigParse.jsonc(updated, checked.path), checked.path)
|
||||
const mode = checked.exists
|
||||
? await stat(checked.path).then((info) => info.mode & 0o777)
|
||||
: checked.scope === "global"
|
||||
? 0o600
|
||||
: undefined
|
||||
if (updated !== before) await (input.write ?? Filesystem.write)(checked.path, updated, mode)
|
||||
return { ok: true, target: await KilocodeConfigOverlay.target(input) }
|
||||
}
|
||||
|
||||
function patchJsonc(input: string, patch: unknown, parts: string[] = []): string {
|
||||
if (!isRecord(patch)) {
|
||||
return applyEdits(
|
||||
input,
|
||||
modify(input, parts, patch === null ? undefined : patch, {
|
||||
formattingOptions: { insertSpaces: true, tabSize: 2 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
const tree = parseTree(input)
|
||||
const node = tree && findNodeAtLocation(tree, parts)
|
||||
if (node && node.type !== "object") {
|
||||
const replacement = parts[0] === "permission" && parts.length === 2 ? { "*": node.value, ...patch } : patch
|
||||
return applyEdits(
|
||||
input,
|
||||
modify(input, parts, replacement, { formattingOptions: { insertSpaces: true, tabSize: 2 } }),
|
||||
)
|
||||
}
|
||||
}
|
||||
return Object.entries(patch).reduce((text, [key, value]) => patchJsonc(text, value, [...parts, key]), input)
|
||||
}
|
||||
}
|
||||
@@ -22,15 +22,18 @@ import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { Event as IndexingEvent, Warning as IndexingWarningEvent } from "./indexing-event"
|
||||
import { indexingWarningKey, type IndexingWarning } from "./indexing-warning"
|
||||
import { IndexingWorker } from "./indexing-worker-client"
|
||||
import { LanceDBRuntime } from "./lancedb" // kilocode_change
|
||||
import { indexingWithKiloDefault, resolveKiloIndexingAuth, type KiloIndexingAuth } from "./indexing-auth" // kilocode_change
|
||||
import { LanceDBRuntime } from "./lancedb"
|
||||
import { indexingWithKiloDefault, resolveKiloIndexingAuth, type KiloIndexingAuth } from "./indexing-auth"
|
||||
import { primaryWorktree } from "./primary-worktree"
|
||||
|
||||
const log = Log.create({ service: "kilocode-indexing" })
|
||||
const auth = makeRuntime(Auth.Service, Auth.defaultLayer)
|
||||
const consent = new Map<string, boolean>()
|
||||
const missing = () => disabledIndexingStatus("Indexing plugin is not enabled for this workspace.")
|
||||
const noWorkspace = () =>
|
||||
disabledIndexingStatus("Codebase indexing is disabled because no workspace folder is open in VS Code.")
|
||||
const noConsent = () =>
|
||||
disabledIndexingStatus("Codebase indexing is disabled until you enable it for this project in Kilo Settings.")
|
||||
|
||||
export const IndexingModelError = NamedError.create("IndexingModelError", {
|
||||
model: Schema.String,
|
||||
@@ -236,6 +239,7 @@ export namespace KiloIndexing {
|
||||
export const Warning = IndexingWarningEvent
|
||||
|
||||
const cache = new Map<string, Cache>()
|
||||
const projects = new Map<string, string>()
|
||||
|
||||
const inert = async (current: () => Status): Promise<Entry> => {
|
||||
const publish = async () => {
|
||||
@@ -269,9 +273,14 @@ export namespace KiloIndexing {
|
||||
)
|
||||
const baseline = startup.baseline
|
||||
const cfg = startup.cfg
|
||||
const project = (await AppRuntime.runPromise(primaryWorktree(dir))) ?? dir
|
||||
projects.set(dir, project)
|
||||
if (process.env["KILO_DISABLE_CODEBASE_INDEXING"] === "vscode-no-workspace") {
|
||||
return track(hit, await inert(() => noWorkspace()))
|
||||
}
|
||||
if (process.env["KILO_PLATFORM"] === "vscode" && !consent.get(project)) {
|
||||
return track(hit, await inert(() => noConsent()))
|
||||
}
|
||||
if (!hasIndexingPlugin(cfg.plugin)) {
|
||||
return track(hit, await inert(() => missing()))
|
||||
}
|
||||
@@ -281,10 +290,16 @@ export namespace KiloIndexing {
|
||||
const auth = await kiloAuth(cfg)
|
||||
const globalConfig = await AppRuntime.runPromise(Config.Service.use((svc) => svc.getGlobal()))
|
||||
const global = globalConfig.indexing
|
||||
const merged = indexingWithKiloDefault({ ...global, ...cfg.indexing }, auth)
|
||||
const merged = indexingWithKiloDefault({ ...global, ...cfg.indexing }, auth) ?? {}
|
||||
let cfgInput: Awaited<ReturnType<typeof model>>
|
||||
try {
|
||||
cfgInput = await model(enrichKilo(input(merged, global), auth), auth)
|
||||
cfgInput = await model(
|
||||
enrichKilo(
|
||||
input({ ...merged, enabled: process.env["KILO_PLATFORM"] === "vscode" ? true : merged.enabled }, global),
|
||||
auth,
|
||||
),
|
||||
auth,
|
||||
)
|
||||
} catch (err) {
|
||||
log.warn("indexing model resolution failed", { err })
|
||||
return track(hit, await inert(() => failed(err)))
|
||||
@@ -478,6 +493,7 @@ export namespace KiloIndexing {
|
||||
registerDisposer(async (dir) => {
|
||||
const hit = cache.get(dir)
|
||||
cache.delete(dir)
|
||||
projects.delete(dir)
|
||||
if (hit) hit.disposed = true
|
||||
if (hit?.entry) {
|
||||
await hit.entry.dispose()
|
||||
@@ -493,6 +509,22 @@ export namespace KiloIndexing {
|
||||
await current.ready
|
||||
}
|
||||
|
||||
/** VS Code supplies machine-local project consent before indexing can start. */
|
||||
export async function setConsent(enabled: boolean) {
|
||||
if (process.env["KILO_PLATFORM"] !== "vscode") return
|
||||
const dir = Instance.directory
|
||||
const project = (await AppRuntime.runPromise(primaryWorktree(dir))) ?? dir
|
||||
if (consent.get(project) === enabled) return
|
||||
consent.set(project, enabled)
|
||||
const hits = [...cache.entries()].filter(([path]) => (projects.get(path) ?? path) === project)
|
||||
for (const [path, hit] of hits) {
|
||||
cache.delete(path)
|
||||
projects.delete(path)
|
||||
hit.disposed = true
|
||||
await hit.entry?.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function current(): Promise<Status> {
|
||||
const entry = await hit().ready
|
||||
entry.scope(WorkspaceContext.workspaceID)
|
||||
|
||||
@@ -20,6 +20,14 @@ const TuiScoped = TuiScope.annotate({ default: "project" })
|
||||
const ProjectScope = Schema.Literal("project").annotate({ default: "project" })
|
||||
const Origin = Schema.Literals(["project", "global", "system", "default"])
|
||||
const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown)
|
||||
const ConfigTarget = Schema.Struct({
|
||||
scope: Scope,
|
||||
path: Schema.String,
|
||||
revision: Schema.String,
|
||||
exists: Schema.Boolean,
|
||||
writable: Schema.Boolean,
|
||||
raw: UnknownRecord,
|
||||
})
|
||||
const ModelRef = Schema.Struct({ providerID: Schema.String, modelID: Schema.String })
|
||||
const Resolved = Schema.Struct({
|
||||
key: Schema.String,
|
||||
@@ -50,10 +58,23 @@ export const ConfigOverlayQuery = Schema.Struct({
|
||||
scope: Schema.optional(Scoped),
|
||||
})
|
||||
export const ConfigOverlayPatch = Schema.Struct({
|
||||
scope: Schema.optional(Scoped),
|
||||
scope: Scope,
|
||||
set: Schema.optional(UnknownRecord),
|
||||
unset: Schema.optional(Schema.Array(Schema.Array(Schema.String))),
|
||||
// Optional: clients that did not read a revision (anything but the settings
|
||||
// page) still write unconditionally instead of failing the request.
|
||||
expected: Schema.optional(Schema.Struct({ path: Schema.String, revision: Schema.String })),
|
||||
})
|
||||
export class ConfigOverlayConflictError extends Schema.ErrorClass<ConfigOverlayConflictError>(
|
||||
"ConfigOverlayConflictError",
|
||||
)(
|
||||
{
|
||||
code: Schema.Literals(["target-changed", "revision-conflict"]),
|
||||
message: Schema.String,
|
||||
target: ConfigTarget,
|
||||
},
|
||||
{ httpApiStatus: 409 },
|
||||
) {}
|
||||
export const ConfigRulesQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
scope: Schema.optional(ProjectScope),
|
||||
@@ -81,9 +102,9 @@ export const ConfigOverlayResponse = Schema.Struct({
|
||||
project: Config.Info,
|
||||
sources: Schema.Array(Source),
|
||||
targets: Schema.Struct({
|
||||
global: Schema.optional(Schema.String),
|
||||
project: Schema.optional(Schema.String),
|
||||
active: Schema.optional(Schema.String),
|
||||
global: ConfigTarget,
|
||||
project: ConfigTarget,
|
||||
active: ConfigTarget,
|
||||
}),
|
||||
fields: Schema.Record(Schema.String, Resolved),
|
||||
collections: Schema.Record(Schema.String, Schema.Array(Resolved)),
|
||||
@@ -177,7 +198,8 @@ export const ConfigConsoleApi = HttpApi.make("config-console")
|
||||
HttpApiEndpoint.patch("overlayUpdate", ConfigConsolePaths.overlay, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ConfigOverlayPatch,
|
||||
success: described(Config.Info, "Effective configuration after patch"),
|
||||
success: described(ConfigOverlayResponse, "Resolved config overlay after patch"),
|
||||
error: ConfigOverlayConflictError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "config.overlayUpdate",
|
||||
|
||||
@@ -26,9 +26,13 @@ export const KiloEmbeddingModelCatalog = Schema.Struct({
|
||||
}).annotate({ identifier: "KiloEmbeddingModelCatalog" })
|
||||
|
||||
const root = "/indexing"
|
||||
const IndexingConsent = Schema.Struct({
|
||||
enabled: Schema.Boolean,
|
||||
})
|
||||
|
||||
export const IndexingPaths = {
|
||||
status: `${root}/status`,
|
||||
consent: `${root}/consent`,
|
||||
models: `${root}/models`,
|
||||
warnings: `${root}/warnings`,
|
||||
} as const
|
||||
@@ -70,6 +74,19 @@ export const IndexingApi = HttpApi.make("indexing")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.put("consent", IndexingPaths.consent, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: IndexingConsent,
|
||||
success: described(IndexingStatusInfo, "Indexing status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "indexing.consent",
|
||||
summary: "Set indexing consent",
|
||||
description: "Set machine-local code indexing consent for the active project.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "indexing",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Auth } from "@/auth"
|
||||
import { Config } from "@/config/config"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { KilocodeConfigOverlay } from "@/kilocode/config/overlay"
|
||||
import { KilocodeConfigWriter } from "@/kilocode/config/writer"
|
||||
import { KilocodeConfigSources } from "@/kilocode/config/sources"
|
||||
import { KilocodeModelState } from "@/kilocode/config/model-state"
|
||||
import { ConfigRules } from "@/kilocode/server/routes/config-rules"
|
||||
@@ -11,10 +12,13 @@ import { KilocodeTuiConfig } from "@/kilocode/tui/config"
|
||||
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
|
||||
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
|
||||
import { markInstanceForDisposal } from "@/server/routes/instance/httpapi/lifecycle"
|
||||
import { InvalidRequestError } from "@/server/routes/instance/httpapi/errors"
|
||||
import { Effect, Option } from "effect"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import {
|
||||
ConfigModelStatePatch,
|
||||
ConfigOverlayConflictError,
|
||||
ConfigOverlayPatch,
|
||||
ConfigOverlayQuery,
|
||||
ConfigRulesPatch,
|
||||
@@ -27,6 +31,7 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf
|
||||
const config = yield* Config.Service
|
||||
const auth = yield* Auth.Service
|
||||
const account = yield* Account.Service
|
||||
const flock = yield* EffectFlock.Service
|
||||
|
||||
const overlay = Effect.fn("ConfigConsoleHttpApi.overlay")(function* (ctx: {
|
||||
query: typeof ConfigOverlayQuery.Type
|
||||
@@ -69,28 +74,78 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf
|
||||
}) {
|
||||
const body = {
|
||||
...ctx.payload,
|
||||
scope: ctx.payload.scope ?? "project",
|
||||
set: ctx.payload.set ? { ...ctx.payload.set } : undefined,
|
||||
unset: ctx.payload.unset?.map((item) => [...item]),
|
||||
}
|
||||
const patch = KilocodeConfigOverlay.patch(body)
|
||||
if (Object.keys(patch).length === 0) {
|
||||
if (body.scope === "global") return yield* config.getGlobal()
|
||||
return yield* config.get()
|
||||
}
|
||||
if (body.scope === "global") {
|
||||
const hot = Object.keys(patch).every((key) => key === "console")
|
||||
const result = yield* config.updateGlobal(patch, hot ? { dispose: false } : undefined)
|
||||
if (result.changed && !hot) {
|
||||
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }).pipe(
|
||||
Effect.catchCause(() => Effect.void),
|
||||
const expected = body.expected ? { ...body.expected } : undefined
|
||||
const instance = yield* InstanceState.context
|
||||
const result = yield* flock
|
||||
.withLock(
|
||||
Effect.promise(() =>
|
||||
KilocodeConfigWriter.write({
|
||||
...body,
|
||||
directory: instance.directory,
|
||||
worktree: instance.worktree,
|
||||
expected,
|
||||
}),
|
||||
),
|
||||
`config:${body.scope}:${expected?.path ?? "target"}`,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (!result.ok) {
|
||||
if (result.code === "target-not-writable") {
|
||||
return yield* Effect.fail(
|
||||
new InvalidRequestError({ message: result.message, kind: result.code, field: result.target.path }),
|
||||
)
|
||||
}
|
||||
return result.info
|
||||
return yield* Effect.fail(
|
||||
new ConfigOverlayConflictError({ code: result.code, message: result.message, target: result.target }),
|
||||
)
|
||||
}
|
||||
yield* config.update(patch)
|
||||
yield* markInstanceForDisposal(yield* InstanceState.context)
|
||||
return yield* config.get()
|
||||
const patch = KilocodeConfigOverlay.patch(body)
|
||||
const hot = body.scope === "global" && Object.keys(patch).every((key) => key === "console")
|
||||
if (body.scope === "global") {
|
||||
yield* config.invalidate()
|
||||
} else {
|
||||
yield* config.update({})
|
||||
yield* markInstanceForDisposal(instance)
|
||||
}
|
||||
const all = yield* auth.all().pipe(Effect.orElseSucceed(() => ({})))
|
||||
const active = yield* account.active().pipe(
|
||||
Effect.map(Option.getOrUndefined),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
const [base, global, sources] = yield* Effect.all(
|
||||
[
|
||||
config.get(),
|
||||
config.getGlobal(),
|
||||
Effect.promise(() =>
|
||||
KilocodeConfigSources.list({
|
||||
directory: instance.directory,
|
||||
worktree: instance.worktree,
|
||||
auth: all,
|
||||
account: active,
|
||||
}),
|
||||
),
|
||||
],
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
const output = yield* Effect.promise(() =>
|
||||
KilocodeConfigOverlay.resolve({
|
||||
directory: instance.directory,
|
||||
worktree: instance.worktree,
|
||||
scope: body.scope,
|
||||
effective: base,
|
||||
global,
|
||||
sources: sources.sources,
|
||||
}),
|
||||
)
|
||||
if (body.scope === "global" && !hot) {
|
||||
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }).pipe(
|
||||
Effect.catchCause(() => Effect.void),
|
||||
)
|
||||
}
|
||||
return output
|
||||
})
|
||||
|
||||
const sources = Effect.fn("ConfigConsoleHttpApi.sources")(function* () {
|
||||
|
||||
@@ -9,6 +9,10 @@ export const indexingHandlers = HttpApiBuilder.group(InstanceHttpApi, "indexing"
|
||||
const status = Effect.fn("IndexingHttpApi.status")(function* () {
|
||||
return yield* EffectBridge.fromPromise(() => mod.KiloIndexing.current())
|
||||
})
|
||||
const consent = Effect.fn("IndexingHttpApi.consent")(function* (ctx: { payload: { enabled: boolean } }) {
|
||||
yield* EffectBridge.fromPromise(() => mod.KiloIndexing.setConsent(ctx.payload.enabled))
|
||||
return yield* EffectBridge.fromPromise(() => mod.KiloIndexing.current())
|
||||
})
|
||||
const models = Effect.fn("IndexingHttpApi.models")(function* () {
|
||||
return yield* EffectBridge.fromPromise(() => mod.KiloIndexing.models())
|
||||
})
|
||||
@@ -16,6 +20,10 @@ export const indexingHandlers = HttpApiBuilder.group(InstanceHttpApi, "indexing"
|
||||
return yield* EffectBridge.fromPromise(() => mod.KiloIndexing.warnings())
|
||||
})
|
||||
|
||||
return handlers.handle("status", status).handle("models", models).handle("warnings", warnings)
|
||||
return handlers
|
||||
.handle("status", status)
|
||||
.handle("consent", consent)
|
||||
.handle("models", models)
|
||||
.handle("warnings", warnings)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { errorLayer } from "@/server/routes/instance/httpapi/middleware/error"
|
||||
import { fenceLayer } from "@/server/routes/instance/httpapi/middleware/fence"
|
||||
import * as AnacondaDesktop from "@/kilocode/anaconda-desktop/service"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
|
||||
import { KiloViewers } from "@/kilocode/presence/service" // kilocode_change
|
||||
import { agentBuilderHandlers } from "./handlers/agent-builder"
|
||||
@@ -66,6 +67,7 @@ export function provideListener(opts?: CorsOptions) {
|
||||
fenceLayer,
|
||||
cors,
|
||||
KiloViewers.defaultLayer, // kilocode_change
|
||||
EffectFlock.defaultLayer,
|
||||
FetchHttpClient.layer,
|
||||
HttpServer.layerServices,
|
||||
Layer.succeed(CorsConfig)(opts),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpClient, HttpMiddleware, HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock" // kilocode_change
|
||||
import * as Observability from "@opencode-ai/core/observability"
|
||||
import { Account } from "@/account/account"
|
||||
import { Agent } from "@/agent/agent"
|
||||
@@ -308,6 +309,7 @@ export function createRoutes(
|
||||
KiloViewers.defaultLayer,
|
||||
SyncEvent.defaultLayer,
|
||||
// kilocode_change end
|
||||
EffectFlock.defaultLayer, // kilocode_change
|
||||
HttpServer.layerServices,
|
||||
]),
|
||||
Layer.provide(LayerNode.buildLayer(app)),
|
||||
@@ -318,9 +320,15 @@ export function createRoutes(
|
||||
|
||||
// kilocode_change start - keep listener routes local while application services come from AppRuntime
|
||||
export function createListenerRoutes(corsOptions?: CorsOptions) {
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, serverRoutes, docRoute, uiRoute).pipe(
|
||||
provideKiloListenerRoutes(corsOptions),
|
||||
)
|
||||
return Layer.mergeAll(
|
||||
rootApiRoutes,
|
||||
eventApiRoutes,
|
||||
ptyConnectApiRoutes,
|
||||
instanceRoutes,
|
||||
serverRoutes,
|
||||
docRoute,
|
||||
uiRoute,
|
||||
).pipe(provideKiloListenerRoutes(corsOptions))
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import { Effect } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -79,6 +80,7 @@ const staleKilo: Partial<Config.Info> = {
|
||||
}
|
||||
const configDir = process.env["KILO_CONFIG_DIR"]
|
||||
const disabled = process.env["KILO_DISABLE_CODEBASE_INDEXING"]
|
||||
const platform = process.env["KILO_PLATFORM"]
|
||||
const error = new Error("test indexing initialization failed")
|
||||
|
||||
function inline(directory: string, root: string, hooks: IndexingWorker.Hooks): IndexingWorker.Driver {
|
||||
@@ -118,6 +120,7 @@ async function called(init: ReturnType<typeof spyOn<CodeIndexManager, "initializ
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env["KILO_PLATFORM"] = "cli"
|
||||
IndexingWorker.override(inline)
|
||||
})
|
||||
|
||||
@@ -127,6 +130,8 @@ afterEach(async () => {
|
||||
else process.env["KILO_CONFIG_DIR"] = configDir
|
||||
if (disabled === undefined) delete process.env["KILO_DISABLE_CODEBASE_INDEXING"]
|
||||
else process.env["KILO_DISABLE_CODEBASE_INDEXING"] = disabled
|
||||
if (platform === undefined) delete process.env["KILO_PLATFORM"]
|
||||
else process.env["KILO_PLATFORM"] = platform
|
||||
global.fetch = fetch
|
||||
await disposeAllInstances()
|
||||
})
|
||||
@@ -388,14 +393,17 @@ describe("indexing startup degradation", () => {
|
||||
},
|
||||
})
|
||||
expect(config.status).toBe(200)
|
||||
await called(init)
|
||||
|
||||
const status = await app.request("/indexing/status", {
|
||||
const status = await app.request("/indexing/consent", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-kilo-directory": tmp.path,
|
||||
},
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
})
|
||||
expect(status.status).toBe(200)
|
||||
await called(init)
|
||||
|
||||
const body = await status.json()
|
||||
expect(body).toMatchObject({
|
||||
@@ -629,6 +637,99 @@ describe("indexing startup degradation", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("requires explicit VS Code consent even when repository config enables indexing", async () => {
|
||||
const created: string[] = []
|
||||
IndexingWorker.override((directory) => {
|
||||
created.push(directory)
|
||||
return inline(directory, "/index", {
|
||||
status() {},
|
||||
telemetry() {},
|
||||
warning() {},
|
||||
log() {},
|
||||
failure() {},
|
||||
})
|
||||
})
|
||||
|
||||
await using tmp = await tmpdir({ git: true, config: cfg })
|
||||
process.env["KILO_CONFIG_DIR"] = tmp.path
|
||||
process.env["KILO_PLATFORM"] = "vscode"
|
||||
|
||||
try {
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
init: Effect.promise(() => KiloIndexing.setConsent(false)),
|
||||
fn: async () => {
|
||||
const status = await KiloIndexing.current()
|
||||
expect(status.state).toBe("Disabled")
|
||||
expect(status.message).toContain("enable it for this project")
|
||||
expect(created).toEqual([])
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
process.env["KILO_PLATFORM"] = "cli"
|
||||
}
|
||||
})
|
||||
|
||||
test("shares consent across linked worktrees and revokes every project worker", async () => {
|
||||
const created: string[] = []
|
||||
const disposed: string[] = []
|
||||
IndexingWorker.override((directory) => {
|
||||
created.push(directory)
|
||||
return {
|
||||
async init() {
|
||||
return {
|
||||
state: "Standby",
|
||||
message: "Indexing paused.",
|
||||
processedFiles: 0,
|
||||
totalFiles: 0,
|
||||
percent: 0,
|
||||
}
|
||||
},
|
||||
async search() {
|
||||
return []
|
||||
},
|
||||
async dispose() {
|
||||
disposed.push(directory)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
await using tmp = await tmpdir({ git: true, config: cfg })
|
||||
const worktree = path.join(path.dirname(tmp.path), `indexing-worktree-${Date.now()}`)
|
||||
await $`git worktree add --quiet -b indexing-consent-${Date.now()} ${worktree} HEAD`.cwd(tmp.path)
|
||||
process.env["KILO_CONFIG_DIR"] = tmp.path
|
||||
process.env["KILO_PLATFORM"] = "vscode"
|
||||
|
||||
try {
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloIndexing.setConsent(true)
|
||||
await wait(() => KiloIndexing.current(), "Standby")
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: worktree,
|
||||
fn: async () => expect((await wait(() => KiloIndexing.current(), "Standby")).state).toBe("Standby"),
|
||||
})
|
||||
expect(new Set(created)).toEqual(new Set([tmp.path, worktree]))
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: () => KiloIndexing.setConsent(false),
|
||||
})
|
||||
|
||||
expect(new Set(disposed)).toEqual(new Set([tmp.path, worktree]))
|
||||
await withTestInstance({
|
||||
directory: worktree,
|
||||
fn: async () => expect((await KiloIndexing.current()).state).toBe("Disabled"),
|
||||
})
|
||||
} finally {
|
||||
process.env["KILO_PLATFORM"] = "cli"
|
||||
await $`git worktree remove --force ${worktree}`.cwd(tmp.path).quiet()
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
test("enriches Kilo provider config from env auth", async () => {
|
||||
global.fetch = (() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { chmod, rm, stat, symlink } from "fs/promises"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Server } from "../../../src/server/server"
|
||||
import { Config } from "../../../src/config/config"
|
||||
import { KilocodeConfigOverlay } from "../../../src/kilocode/config/overlay"
|
||||
import { KilocodeConfigWriter } from "../../../src/kilocode/config/writer"
|
||||
import { Permission } from "../../../src/permission"
|
||||
import { PtyPaths } from "../../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { Filesystem } from "../../../src/util/filesystem"
|
||||
@@ -12,14 +14,16 @@ import { resetDatabase } from "../../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
setDefaultTimeout(30_000)
|
||||
|
||||
const original = Global.Path.config
|
||||
const terminal = process.platform === "win32" ? test.skip : test.serial
|
||||
|
||||
type Target = { path: string; revision: string; exists: boolean; writable: boolean; raw: Record<string, unknown> }
|
||||
type Overlay = {
|
||||
fields: Record<string, { source: string; inherited: boolean; overridden: boolean; value?: unknown }>
|
||||
collections: Record<string, Array<{ key: string; source: string; inherited: boolean; local?: unknown }>>
|
||||
targets: { project?: string; global?: string; active?: string }
|
||||
targets: { project: Target; global: Target; active: Target }
|
||||
}
|
||||
type Agent = {
|
||||
name: string
|
||||
@@ -30,34 +34,41 @@ afterEach(async () => {
|
||||
;(Global.Path as { config: string }).config = original
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
}, 15_000)
|
||||
|
||||
function req(dir: string, input: string, init?: RequestInit) {
|
||||
return Server.Default().app.request(input, {
|
||||
...init,
|
||||
headers: {
|
||||
"x-kilo-directory": dir,
|
||||
...init?.headers,
|
||||
},
|
||||
})
|
||||
return request(Server.Default().app, dir, input, init)
|
||||
}
|
||||
|
||||
function app(_value: boolean) {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function request(target: ReturnType<typeof app>, dir: string | undefined, input: string, init?: RequestInit) {
|
||||
async function request(target: ReturnType<typeof app>, dir: string | undefined, input: string, init?: RequestInit) {
|
||||
const headers = {
|
||||
...(dir ? { "x-kilo-directory": dir } : {}),
|
||||
...init?.headers,
|
||||
}
|
||||
const body = init?.method === "PATCH" && input === "/config/overlay" ? JSON.parse(String(init.body)) : undefined
|
||||
const next =
|
||||
body && !body.expected
|
||||
? await (async () => {
|
||||
const scope = body.scope === "global" ? "global" : "project"
|
||||
const response = await target.request(`/config/overlay?scope=${scope}`, { headers })
|
||||
const overlay = (await response.json()) as Overlay
|
||||
const expected = overlay.targets[scope]
|
||||
return { ...body, expected: { path: expected.path, revision: expected.revision } }
|
||||
})()
|
||||
: body
|
||||
return target.request(input, {
|
||||
...init,
|
||||
headers: {
|
||||
...(dir ? { "x-kilo-directory": dir } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
headers,
|
||||
body: next ? JSON.stringify(next) : init?.body,
|
||||
})
|
||||
}
|
||||
|
||||
async function json<T>(response: Response) {
|
||||
expect(response.status).toBe(200)
|
||||
if (response.status !== 200) throw new Error(`HTTP ${response.status}: ${await response.text()}`)
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
@@ -77,6 +88,186 @@ async function setGlobal(dir: string, value: Config.Info) {
|
||||
}
|
||||
|
||||
describe("config overlay routes", () => {
|
||||
test("writes a missing project target atomically", async () => {
|
||||
await using project = await tmpdir()
|
||||
const target = await KilocodeConfigOverlay.target({ scope: "project", directory: project.path })
|
||||
|
||||
const result = await KilocodeConfigWriter.write({
|
||||
scope: "project",
|
||||
directory: project.path,
|
||||
expected: target,
|
||||
set: { model: "test/model" },
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(await Bun.file(target.path).text()).toContain('"model": "test/model"')
|
||||
})
|
||||
|
||||
test("returns exact raw target data and a stable missing-file revision", async () => {
|
||||
await using project = await tmpdir()
|
||||
const first = await KilocodeConfigOverlay.target({ scope: "project", directory: project.path })
|
||||
const second = await KilocodeConfigOverlay.target({ scope: "project", directory: project.path })
|
||||
|
||||
expect(first.exists).toBe(false)
|
||||
expect(first.raw).toEqual({})
|
||||
expect(first.revision).toBe(second.revision)
|
||||
|
||||
await Filesystem.write(first.path, '{\n // preserved\n "model": "test/model"\n}\n')
|
||||
const saved = await KilocodeConfigOverlay.target({ scope: "project", directory: project.path })
|
||||
expect(saved.raw).toEqual({ model: "test/model" })
|
||||
expect(saved.revision).not.toBe(first.revision)
|
||||
})
|
||||
|
||||
test("rejects a comment-only external edit with a typed revision conflict", async () => {
|
||||
await using project = await tmpdir()
|
||||
const before = await json<Overlay>(await req(project.path, "/config/overlay?scope=project"))
|
||||
await Filesystem.write(before.targets.project.path, "{\n // external edit\n}\n")
|
||||
|
||||
const response = await Server.Default().app.request("/config/overlay", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json", "x-kilo-directory": project.path },
|
||||
body: JSON.stringify({
|
||||
scope: "project",
|
||||
expected: {
|
||||
path: before.targets.project.path,
|
||||
revision: before.targets.project.revision,
|
||||
},
|
||||
set: { model: "test/model" },
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(await response.json()).toMatchObject({ code: "revision-conflict" })
|
||||
})
|
||||
|
||||
test("rejects a newly created higher-priority target", async () => {
|
||||
await using project = await tmpdir()
|
||||
const before = await json<Overlay>(await req(project.path, "/config/overlay?scope=project"))
|
||||
await Filesystem.write(path.join(project.path, "kilo.json"), "{}")
|
||||
|
||||
const response = await Server.Default().app.request("/config/overlay", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json", "x-kilo-directory": project.path },
|
||||
body: JSON.stringify({
|
||||
scope: "project",
|
||||
expected: {
|
||||
path: before.targets.project.path,
|
||||
revision: before.targets.project.revision,
|
||||
},
|
||||
set: { model: "test/model" },
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(await response.json()).toMatchObject({ code: "target-changed" })
|
||||
})
|
||||
|
||||
test("allows only one concurrent writer for a revision", async () => {
|
||||
await using project = await tmpdir()
|
||||
const before = await json<Overlay>(await req(project.path, "/config/overlay?scope=project"))
|
||||
const update = (model: string) =>
|
||||
Server.Default().app.request("/config/overlay", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json", "x-kilo-directory": project.path },
|
||||
body: JSON.stringify({
|
||||
scope: "project",
|
||||
expected: {
|
||||
path: before.targets.project.path,
|
||||
revision: before.targets.project.revision,
|
||||
},
|
||||
set: { model },
|
||||
}),
|
||||
})
|
||||
|
||||
const responses = await Promise.all([update("test/first"), update("test/second")])
|
||||
expect(responses.map((response) => response.status).sort()).toEqual([200, 409])
|
||||
})
|
||||
|
||||
test("rejects a project config target that escapes through a symlink", async () => {
|
||||
if (process.platform === "win32") return
|
||||
await using project = await tmpdir()
|
||||
await using outside = await tmpdir()
|
||||
await Filesystem.write(path.join(outside.path, "kilo.jsonc"), "{}")
|
||||
await symlink(outside.path, path.join(project.path, ".kilo"), "dir")
|
||||
const before = await json<Overlay>(await req(project.path, "/config/overlay?scope=project"))
|
||||
|
||||
const response = await Server.Default().app.request("/config/overlay", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json", "x-kilo-directory": project.path },
|
||||
body: JSON.stringify({
|
||||
scope: "project",
|
||||
expected: {
|
||||
path: before.targets.project.path,
|
||||
revision: before.targets.project.revision,
|
||||
},
|
||||
set: { model: "test/model" },
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await Bun.file(path.join(outside.path, "kilo.jsonc")).text()).not.toContain('"model"')
|
||||
})
|
||||
|
||||
test("does not expose partial content when an atomic replacement fails", async () => {
|
||||
await using project = await tmpdir()
|
||||
const file = path.join(project.path, "kilo.jsonc")
|
||||
await Filesystem.write(file, '{\n "model": "test/before"\n}\n')
|
||||
const target = await KilocodeConfigOverlay.target({ scope: "project", directory: project.path })
|
||||
|
||||
await expect(
|
||||
KilocodeConfigWriter.write({
|
||||
scope: "project",
|
||||
directory: project.path,
|
||||
expected: target,
|
||||
set: { model: "test/after" },
|
||||
write: async () => {
|
||||
throw new Error("simulated replacement failure")
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("simulated replacement failure")
|
||||
expect(await Bun.file(file).text()).toContain("test/before")
|
||||
})
|
||||
|
||||
test("rechecks missing target parents before replacement", async () => {
|
||||
if (process.platform === "win32") return
|
||||
await using project = await tmpdir()
|
||||
await using outside = await tmpdir()
|
||||
const target = await KilocodeConfigOverlay.target({ scope: "project", directory: project.path })
|
||||
|
||||
const result = await KilocodeConfigWriter.write({
|
||||
scope: "project",
|
||||
directory: project.path,
|
||||
expected: target,
|
||||
set: { model: "test/model" },
|
||||
beforeWrite: async () => {
|
||||
await rm(path.dirname(target.path), { recursive: true })
|
||||
await symlink(outside.path, path.dirname(target.path), "dir")
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "target-not-writable" })
|
||||
expect(await Bun.file(path.join(outside.path, "kilo.jsonc")).exists()).toBe(false)
|
||||
})
|
||||
|
||||
test("preserves restrictive config file permissions", async () => {
|
||||
if (process.platform === "win32") return
|
||||
await using project = await tmpdir()
|
||||
const file = path.join(project.path, "kilo.jsonc")
|
||||
await Filesystem.write(file, "{}", 0o600)
|
||||
await chmod(file, 0o600)
|
||||
const target = await KilocodeConfigOverlay.target({ scope: "project", directory: project.path })
|
||||
|
||||
const result = await KilocodeConfigWriter.write({
|
||||
scope: "project",
|
||||
directory: project.path,
|
||||
expected: target,
|
||||
set: { model: "test/model" },
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
test("ignores unsafe patch paths", () => {
|
||||
const patched = KilocodeConfigOverlay.patch({
|
||||
scope: "project",
|
||||
@@ -141,7 +332,7 @@ describe("config overlay routes", () => {
|
||||
prompt: "kilo agent prompt",
|
||||
})
|
||||
expect(body.project.agent?.["opencode-only"]).toBeUndefined()
|
||||
expect(body.targets.project).toBe(path.join(project.path, ".kilo", "kilo.json"))
|
||||
expect(body.targets.project.path).toBe(path.join(project.path, ".kilo", "kilo.json"))
|
||||
})
|
||||
|
||||
test.serial("tolerates unsafe project config instead of failing the overlay", async () => {
|
||||
@@ -393,38 +584,42 @@ describe("config overlay routes", () => {
|
||||
expect(saved.mcp).toEqual({ shared: { enabled: false } })
|
||||
})
|
||||
|
||||
test.serial("refreshes effective config after project permission update", async () => {
|
||||
await using global = await tmpdir()
|
||||
await using project = await tmpdir()
|
||||
await setGlobal(global.path, { permission: { edit: "allow" } })
|
||||
test.serial(
|
||||
"refreshes effective config after project permission update",
|
||||
async () => {
|
||||
await using global = await tmpdir()
|
||||
await using project = await tmpdir()
|
||||
await setGlobal(global.path, { permission: { edit: "allow" } })
|
||||
|
||||
const before = await json<Agent[]>(await req(project.path, "/agent"))
|
||||
expect(Permission.evaluate("edit", "*", before.find((item) => item.name === "code")?.permission ?? []).action).toBe(
|
||||
"allow",
|
||||
)
|
||||
const before = await json<Agent[]>(await req(project.path, "/agent"))
|
||||
expect(
|
||||
Permission.evaluate("edit", "*", before.find((item) => item.name === "code")?.permission ?? []).action,
|
||||
).toBe("allow")
|
||||
|
||||
await json(
|
||||
await req(project.path, "/config/overlay", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ scope: "project", set: { permission: { edit: { "*": "ask" } } } }),
|
||||
}),
|
||||
)
|
||||
const body = await json<Overlay & { effective: { permission: Record<string, string | Record<string, string>> } }>(
|
||||
await req(project.path, "/config/overlay?scope=project"),
|
||||
)
|
||||
const edit = body.effective.permission.edit
|
||||
const after = await json<Agent[]>(await req(project.path, "/agent"))
|
||||
await json(
|
||||
await req(project.path, "/config/overlay", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ scope: "project", set: { permission: { edit: { "*": "ask" } } } }),
|
||||
}),
|
||||
)
|
||||
const body = await json<Overlay & { effective: { permission: Record<string, string | Record<string, string>> } }>(
|
||||
await req(project.path, "/config/overlay?scope=project"),
|
||||
)
|
||||
const edit = body.effective.permission.edit
|
||||
const after = await json<Agent[]>(await req(project.path, "/agent"))
|
||||
|
||||
expect(typeof edit === "string" ? edit : edit["*"]).toBe("ask")
|
||||
expect(Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action).toBe(
|
||||
"ask",
|
||||
)
|
||||
expect(body.collections.permission.find((item) => item.key === "edit")).toMatchObject({
|
||||
source: "project",
|
||||
overridden: true,
|
||||
})
|
||||
})
|
||||
expect(typeof edit === "string" ? edit : edit["*"]).toBe("ask")
|
||||
expect(
|
||||
Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action,
|
||||
).toBe("ask")
|
||||
expect(body.collections.permission.find((item) => item.key === "edit")).toMatchObject({
|
||||
source: "project",
|
||||
overridden: true,
|
||||
})
|
||||
},
|
||||
15_000,
|
||||
)
|
||||
|
||||
test.serial("refreshes agent permissions after global permission update", async () => {
|
||||
await using global = await tmpdir()
|
||||
@@ -512,6 +707,7 @@ describe("config overlay routes", () => {
|
||||
Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action,
|
||||
).toBe("allow")
|
||||
},
|
||||
30_000,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -218,6 +218,11 @@ export const kiloScenarios: Scenario[] = [
|
||||
http.protected.get("/indexing/status", "indexing.status").json(200, object),
|
||||
http.protected.get("/indexing/models", "indexing.models").json(200, object),
|
||||
http.protected.get("/indexing/warnings", "indexing.warnings").json(200, array),
|
||||
http.protected
|
||||
.put("/indexing/consent", "indexing.consent")
|
||||
.mutating()
|
||||
.at((ctx) => ({ path: "/indexing/consent", headers: ctx.headers(), body: { enabled: false } }))
|
||||
.json(200, object),
|
||||
http.protected.get("/memory/status", "memory.status").json(200, (body) => {
|
||||
object(body)
|
||||
object(body.state)
|
||||
|
||||
@@ -461,6 +461,8 @@ describe("kilocode tool registry indexing", () => {
|
||||
})
|
||||
|
||||
test("logs indexing bootstrap failures without blocking session bootstrap", async () => {
|
||||
const platform = process.env["KILO_PLATFORM"]
|
||||
process.env["KILO_PLATFORM"] = "cli"
|
||||
const logger = Log.create({ service: "kilocode-bootstrap" })
|
||||
const err = new Error("indexing init failed")
|
||||
const calls: string[] = []
|
||||
@@ -504,6 +506,8 @@ describe("kilocode tool registry indexing", () => {
|
||||
expect(indexing).toHaveBeenCalledTimes(1)
|
||||
expect(warn).toHaveBeenCalledWith("indexing bootstrap failed", { err })
|
||||
} finally {
|
||||
if (platform === undefined) delete process.env["KILO_PLATFORM"]
|
||||
else process.env["KILO_PLATFORM"] = platform
|
||||
indexing.mockRestore()
|
||||
warn.mockRestore()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user