Merge pull request #12566 from Kilo-Org/abalone-bactrosaurus

feat(vscode): multi-project Agent Manager
This commit is contained in:
Marius
2026-07-30 18:07:46 +02:00
committed by GitHub
170 changed files with 13947 additions and 1808 deletions
+11 -8
View File
@@ -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)
}
}
+36 -4
View File
@@ -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()
}