mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 14:07:20 +08:00
+25







![opencode-agent[bot]](/assets/img/avatar_default.png)

Johnny Eric Amancio
opencode-agent[bot]
LukeParkerDev
Shoubhit Dash
Simon Klee
Brendan Allan
Brendan Allan
starptech
Cortex Code
Dax
Dax Raad
Frank
opencode-agent[bot]
Aiden Cline
Michael Hart
smagnuso
Orca丶
Adam
Aarav Sareen
Kit Langton
James Long
Dustin Deus
Ulises Jeremias
Jack
Jérôme Benoit
Ariane Emory
LIU Xinyu
Colin McDonnell
Sebastian
opencode
Kamesh Sampath
pcadena-lila
weiconghe
alberto
kilo-maintainer[bot]
ef6b152ff8
* feat(worktree): add managed workspace cloning (#30117) * test(tui): skip crashing keymap textarea renderer * fix(core): allow skipping migration execution * fix(opencode): remove automatic full session diffs (#30127) * chore: generate * refactor(worktree): move project out of repository * zen: deepseek flash * fix(tui): remount session view on session switch (#30129) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * go: minimax m3 * refactor(opencode): inline local provider helpers (#30169) * refactor(opencode): simplify provider setup flow (#30173) * fix(app): show project sessions before path sync resolves (#30167) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> * fix(core): preserve session metadata migration identity (#30176) * refactor(session): align namespace imports and inline trivial helpers (#30180) * opencode(run): add queued prompt management (#30103) Direct run mode previously made submitted follow-up prompts irrevocable while a response was still running. Let users edit or remove queued prompts before dispatch without interrupting the active turn. * chore: generate * fix(acp): honor session/cancel by aborting the running turn (#30145) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(tui): prevent prompt corruption when pasting near wide characters (#29710) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> * fix(opencode): avoid nullable webfetch format schema (#30215) * chore: generate * fix(core): contain lsp warmup defects (#30226) * add run --replay mode (#30239) * chore: generate * chore: update nix node_modules hashes * fix(stats): restore leaderboard spacing * fix(stats): center top models dot grid * fix(stats): stabilize top models hover * fix(stats): align big-pickle provider resolution (#30274) * feat(app): v2 desktop UI improvements (#29689) Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> * chore: generate * fix(tui): clarify inline subagent rows (#30051) * fix(tui): handle events across workspaces (#30281) * feat(core): update Copilot for token-based billing (#30181) * fix(tui): keep background marker with subagent label (#30271) * fix(tui): keep retry attempt before message (#30275) * chore: generate * fix(opencode): enforce storage path invariants (#29666) * chore: generate * feat(core): add location-based permission service (#30287) * chore: generate * fix(tui): preserve live parts during session hydration (#30300) * fix(app): restore deferred MCP status updates (#30220) * fix: export v2 stylesheets and declare core node types (#30312) * chore: update nix node_modules hashes * fix(app): avoid suspending on pending child path (#30314) * fix(opencode): remove sunsetted gpt-5.2 and gpt-5.3-codex from allowed models for codex subscriptions (#30316) * chore: generate * feat(core): expose session location * chore: generate * fix(opencode): preserve websocket api errors (#30321) * refactor(core): simplify session pagination * feat(core): add location filesystem contract * feat(core): add dummy location filesystem layer * chore: generate * feat(opencode): add filesystem read and list routes * chore: generate * infra: stats * sync * feat(app): inset new layout session panels (#30342) * fix(app): tab title truncation and close button positioning (#30349) * tui: show model context in run footer (#30380) * tui: revert OpenTUI upgrade to 0.2.16 (#30383) * chore: update nix node_modules hashes * feat(core): add managed repository cache (#30408) * chore: generate * chore: generate * sync * feat(stats): add cache ratio section * feat(core): add flagged project references (#30414) * chore: generate * feat(core): support named migrations (#30418) * fix(stats): clean retired provider rows during sync (#30420) * fix(stats): mention opencode go in top models copy * feat(core): expose project reference filesystem access (#30423) * chore: generate * sync * fix(tui): scope diff viewer to session directory (#30426) * test: widen provider header timeout margin (#30427) * fix(plugin): restore private git install fallback (#30430) * fix(stats): remove leaderboard nav link * chore(opencode): remove scout agent (#30435) * chore: generate * feat(stats): improve cache ratio chart * chore: generate * fix(effect-drizzle-sqlite): preserve transaction begin errors (#30448) * chore: bump effect beta to 74 (#30449) * Revert "tui: revert OpenTUI upgrade to 0.2.16 (#30383)" (#30452) * chore: update nix node_modules hashes * refactor(opencode): improve startup time by 38% (#30453) Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> * chore: generate * fix(opencode): patch empty Gemini replay messages (#30463) * chore: generate * refactor(core): consolidate filesystem services (#30447) * chore: generate * run: enable interactive replay by default (#30465) * chore: update nix node_modules hashes * refactor(opencode): remove JSON storage migration (#30461) * chore: generate * chore: update nix node_modules hashes * fix(tui): stop idle background task spinner (#30484) * refactor(core): move v1 schemas into core (#30473) * chore: generate * fix: task id passed to background job for continuation (#30485) * chore: generate * feat(core): project copying and tracking directories (#30139) * chore: generate * fix(opencode): preserve signed thinking during anthropic reorder (#30182) * Revert "fix(opencode): preserve signed thinking during anthropic reorder" (#30502) * fix: rm tool reorder logic from old bug (#30483) * chore: generate * feat(app): polish home projects list UI (#30436) * feat(app): polish select-v2 component (#30446) Co-authored-by: Brendan Allan <git@brendonovich.dev> * fix(github): enforce existing git author identity (#30507) * feat(app): new update button (#30460) Co-authored-by: Brendan Allan <git@brendonovich.dev> * fix(opencode): fallback to sh for curl upgrade (#30499) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(ui): render whole-file patches as complete diffs (#30516) * chore: generate * feat(app): add servers tab to settings dialog (#29675) * refactor(core): consolidate pty service (#30537) * chore: generate * tui: truncate sidebar file paths (#30531) * chore: update nix node_modules hashes * feat(stats): add geo breakdown (#30456) * chore: generate * chore: update nix node_modules hashes * fix(acp): classify apply_patch as edit (#30564) * fix(acp): classify task as think (#30565) * fix(acp): include external directory permission context (#30567) * fix(acp): clean read tool display content (#30569) * fix(tui): route question responses by session directory (#30578) * fix(stats): serve stats og image from banner * docs(go): add Qwen3.7 Plus model (#30594) * fix(openai): preserve websocket idle state (#30586) * refactor(core): remove ai sdk option fields (#30581) * chore: generate * test(core): cover v1 provider option lowering (#30599) * chore: generate * refactor(core): nest model api id (#30603) * fix(core): expose azure openai xhigh efforts (#30620) * feat(core): add skill registry and file agent loading (#30617) * chore: generate * chore: update nix node_modules hashes * fix(stats): count all go usage * chore: remove zed extension and automation (#30628) * fix(opencode): preserve variant for delegated tasks (#30630) * zen: update nvidia tos * fix(opencode): route SAP AI Core reasoning variants through modelParams (#30482) * chore: generate * fix(app): hide unavailable titlebar update (#30642) * feat(app): v2 thinking level selector (#30646) * fix(app,ui): session review reactivity and VCS query cache (#30660) * feat(core): add embedded v2 session runtime and tool foundation (#30632) * chore: generate * chore: update nix node_modules hashes * docs: correct compaction prune default (#30670) * fix(opencode): avoid shell cancel race (#30641) * feat: bump bedrock and add proper mantle support for openai models through aws bedrock (#30464) * test: wait for shell truncation readiness (#30679) * chore: update nix node_modules hashes * refactor(opencode): clean up task tool prompts (#30687) * feat(core): add command registry (#30624) * chore: generate * fix(acp): replay loaded session transcript (#30645) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(core): reset pre-launch session projections (#30728) * feat(tui): improve experimental session switcher (#30738) * fix(opencode): respect disabled auto compaction on overflow (#30749) * zen: nemotron 3 ultra * fix(enterprise): install hono standard validator peer (#30740) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * fix build * chore: update nix node_modules hashes * make scripts executable * fix(tui): show toast when variant_list keybind used with no variants (#30724) * fix(opencode): `ACP.loadSession` should replay all messages (#30761) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(opencode): attribute task child agent on creation (#30786) * fix(tui): add Vue syntax highlighting (#30802) * fix: bump @openrouter/ai-sdk-provider to 2.9.0 (#30800) * feat(core): moving sessions (#30640) * chore: generate * tweak: background agent prompting to avoid polling issues (#30790) * upgrade opentui to 0.3.2 (#30748) * chore: update nix node_modules hashes * feat(desktop): surface local server startup failures (#30822) * ci: publish * refactor(core): make v2 session inputs event sourced (#30785) * chore: generate * fix(llm): normalize OpenAI function tool schemas * chore: generate * feat(stats): refresh stats routes and homepage (#30419) * fix(stats): sort metric charts by top usage * feat(core): add public native API (#30828) * chore: generate * feat(app): color themes (#30824) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> * chore: generate * sync release versions for v1.16.0 * feat(core): attach global native tools (#30832) * chore: generate * feat(core): add Snowflake Cortex provider (#29901) Co-authored-by: Cortex Code <noreply@snowflake.com> * chore: generate * feat(core): persist v2 session context epochs (#30789) * chore: generate * feat(tui): allow backgrounding synchronous subagents (#30488) * fix(app): improve tab handling (#30669) * chore: generate * fix(tui): prioritize models slash autocomplete (#30848) * fix(tui): route permission replies to session directory (#30851) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * fix(cli): harden daemon lifecycle (#30844) * chore: generate * feat(app): improve desktop multi-server support (#30678) Co-authored-by: Brendan Allan <git@brendonovich.dev> * chore: generate * fix(app): handle tab overflow and scrolling in titlebar (#30886) * fix(app): tab overflow (#30894) * tui: guard path formatting inputs (#30469) Fixes #27726, #25216, #24856, #24294, #17071, #29164, #24837, #16865, #14279, #29895 * opencode/run: refresh themes after terminal reloads (#30917) * chore: generate * fix(tui): fall back to local cwd when editor spawns in attach mode (#30583) * docs: update Go Qwen tiered pricing (#30936) * chore: generate * feat(tui): add diff hunk navigation (#30935) * chore: rm fuzzy search on references (#30931) * fix: use mapError instead of orDie for context snapshot decoding (#30905) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(core): recover corrupted models cache (#30947) * chore: bun install (#30968) * fix(opencode): resolve Bedrock hang by using node build conditions (#30873) * fix(workflows): retry nix-hashes compute-hash on transient failure (#30743) * fix(stats): scroll model charts to latest on mobile * fix(opencode): prevent destructive edit matches (#30932) * chore: generate * fix(core): respect v2 default agents (#30969) * chore: generate * test(opencode): remove disposal event wait race (#30971) * test(opencode): remove shell timeout output race (#30974) * fix(opencode): gate reasoning summaries by provider (#30973) * feat(core): admit v2 skill guidance (#30843) * fix(workflows): serialize desktop release uploads (#30978) * fix(stats): add mobile chart end spacing * release: v1.16.2 * refactor: kilo compat for v1.16.2 * fix(opencode): address v1.16.2 merge regressions * chore: update kilo-vscode visual regression baselines * fix(opencode): restore Kilo behavior after v1.16.2 merge * fix(opencode): retry Windows migration cleanup * test(opencode): restore clone and macOS watcher coverage * fix(opencode): address second-pass review for #12099 Preserve imported usage and retry partial JSON migrations. Refresh active dependency patches, remove the obsolete GCP patch, and regenerate Kilo HttpApi branding. --------- Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Michael Hart <mhart@cloudflare.com> Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: smagnuso <smagnuso@gmail.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Orca丶 <93272799+dauphinYan@users.noreply.github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Ulises Jeremias <ulisescf.24@gmail.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com> Co-authored-by: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Co-authored-by: LIU Xinyu <contact@lxy.cc> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> Co-authored-by: Sebastian <hasta84@gmail.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Kamesh Sampath <kamesh.sampath@hotmail.com> Co-authored-by: Cortex Code <noreply@snowflake.com> Co-authored-by: pcadena-lila <pcadena@lila.ai> Co-authored-by: weiconghe <46336277+weiconghe@users.noreply.github.com> Co-authored-by: alberto <914199+alblez@users.noreply.github.com> Co-authored-by: kilo-maintainer[bot] <kilo-maintainer[bot]@users.noreply.github.com>
2230 lines
71 KiB
TypeScript
2230 lines
71 KiB
TypeScript
import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test"
|
|
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
|
import { Effect, Exit, Layer, Option } from "effect"
|
|
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
|
import { Config } from "@/config/config"
|
|
import { ConfigManaged } from "@/config/managed"
|
|
import { ConfigParse } from "../../src/config/parse"
|
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
|
|
|
import { InstanceRef } from "../../src/effect/instance-ref"
|
|
import type { InstanceContext } from "../../src/project/instance-context"
|
|
import { Auth } from "../../src/auth"
|
|
import { Account } from "../../src/account/account"
|
|
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
|
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
|
import { Env } from "../../src/env"
|
|
import { Git } from "../../src/git" // kilocode_change
|
|
import {
|
|
provideTmpdirInstance,
|
|
TestInstance,
|
|
tmpdir,
|
|
tmpdirScoped,
|
|
withTestInstance,
|
|
provideInstanceEffect,
|
|
testInstanceStoreLayer,
|
|
} from "../fixture/fixture"
|
|
import { InstanceRuntime } from "@/project/instance-runtime"
|
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|
import { testEffect } from "../lib/effect"
|
|
import path from "path"
|
|
import fs from "fs/promises"
|
|
import os from "os"
|
|
import { pathToFileURL } from "url"
|
|
import { Global } from "@opencode-ai/core/global"
|
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
|
import { Filesystem } from "@/util/filesystem"
|
|
import { ConfigPlugin } from "@/config/plugin"
|
|
import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
|
|
import { AccountTest } from "../fake/account"
|
|
import { AuthTest } from "../fake/auth"
|
|
import { NpmTest } from "../fake/npm"
|
|
import { isIndexingPlugin } from "@kilocode/kilo-indexing/detect" // kilocode_change
|
|
import { isAtomicChatPlugin } from "@/kilocode/atomic-chat-feature" // kilocode_change
|
|
|
|
/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
|
|
const infra = CrossSpawnSpawner.defaultLayer.pipe(
|
|
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
|
|
)
|
|
|
|
const testFlock = EffectFlock.defaultLayer
|
|
|
|
const unexpectedHttp = HttpClient.make((request) =>
|
|
Effect.die(`unexpected http request: ${request.method} ${request.url}`),
|
|
)
|
|
|
|
const json = (request: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
|
|
HttpClientResponse.fromWeb(
|
|
request,
|
|
new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
)
|
|
|
|
const wellKnownAuth = (url: string) =>
|
|
Layer.mock(Auth.Service)({
|
|
all: () =>
|
|
Effect.succeed({
|
|
[url]: new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
|
|
}),
|
|
})
|
|
|
|
function remoteConfigClient(input: {
|
|
wellKnown: unknown
|
|
remote?: unknown
|
|
seen: { wellKnown?: string; remote?: string; authorization?: string }
|
|
}) {
|
|
return HttpClient.make((request) => {
|
|
if (request.url.includes(".well-known/opencode")) {
|
|
input.seen.wellKnown = request.url
|
|
return Effect.succeed(json(request, input.wellKnown))
|
|
}
|
|
if (input.remote !== undefined && request.url.includes("config.example.com")) {
|
|
input.seen.remote = request.url
|
|
input.seen.authorization = request.headers.authorization
|
|
return Effect.succeed(json(request, input.remote))
|
|
}
|
|
return Effect.succeed(json(request, {}, 404))
|
|
})
|
|
}
|
|
|
|
const configLayer = (
|
|
options: {
|
|
auth?: Layer.Layer<Auth.Service>
|
|
account?: Layer.Layer<Account.Service>
|
|
client?: HttpClient.HttpClient
|
|
} = {},
|
|
) =>
|
|
Config.layer.pipe(
|
|
Layer.provide(Git.defaultLayer), // kilocode_change
|
|
Layer.provide(testFlock),
|
|
Layer.provide(Env.defaultLayer),
|
|
Layer.provide(options.auth ?? AuthTest.empty),
|
|
Layer.provide(options.account ?? AccountTest.empty),
|
|
Layer.provideMerge(infra),
|
|
Layer.provide(NpmTest.noop),
|
|
Layer.provide(Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)),
|
|
Layer.provideMerge(FSUtil.defaultLayer),
|
|
)
|
|
|
|
const layer = configLayer()
|
|
|
|
const it = testEffect(layer)
|
|
const configIt = (options?: Parameters<typeof configLayer>[0]) => testEffect(configLayer(options))
|
|
|
|
const schemaConfig = (config: object) => ({ $schema: "https://app.kilo.ai/config.json", ...config }) // kilocode_change
|
|
|
|
const provideCurrentInstance = <A, E, R>(effect: Effect.Effect<A, E, R>, ctx: InstanceContext) =>
|
|
effect.pipe(Effect.provideService(InstanceRef, ctx))
|
|
|
|
const load = (ctx: InstanceContext) =>
|
|
Effect.runPromise(
|
|
Config.Service.use((svc) => provideCurrentInstance(svc.get(), ctx)).pipe(Effect.scoped, Effect.provide(layer)),
|
|
)
|
|
const clearEffect = (wait = false) =>
|
|
Config.use
|
|
.invalidate()
|
|
.pipe(
|
|
Effect.scoped,
|
|
Effect.provide(layer),
|
|
Effect.andThen(wait ? Effect.promise(() => InstanceRuntime.disposeAllInstances()) : Effect.void),
|
|
)
|
|
const clear = (wait = false) => Effect.runPromise(clearEffect(wait))
|
|
// Get managed config directory from environment (set in preload.ts)
|
|
const managedConfigDir = process.env.KILO_TEST_MANAGED_CONFIG_DIR!
|
|
const originalTestToken = process.env.TEST_TOKEN
|
|
const originalConsoleToken = process.env.KILO_CONSOLE_TOKEN
|
|
|
|
beforeEach(async () => {
|
|
await clear(true)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
|
|
if (originalTestToken === undefined) delete process.env.TEST_TOKEN
|
|
else process.env.TEST_TOKEN = originalTestToken
|
|
if (originalConsoleToken === undefined) delete process.env.KILO_CONSOLE_TOKEN
|
|
else process.env.KILO_CONSOLE_TOKEN = originalConsoleToken
|
|
await clear(true)
|
|
})
|
|
|
|
const writeManagedSettingsEffect = (settings: object, filename?: string) =>
|
|
FSUtil.use.writeWithDirs(path.join(managedConfigDir, filename ?? "kilo.json"), JSON.stringify(settings)) // kilocode_change
|
|
|
|
// kilocode_change start
|
|
async function writeConfig(dir: string, config: object, name = "kilo.json") {
|
|
// kilocode_change end
|
|
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
|
|
}
|
|
|
|
const writeConfigEffect = (
|
|
dir: string,
|
|
config: object,
|
|
name = "kilo.json", // kilocode_change
|
|
) => FSUtil.use.writeWithDirs(path.join(dir, name), JSON.stringify(config))
|
|
|
|
const withInstanceDir = <A, E, R>(dir: string, effect: Effect.Effect<A, E, R>) =>
|
|
effect.pipe(
|
|
Effect.provideService(TestInstance, { directory: dir }),
|
|
provideInstanceEffect(dir),
|
|
Effect.provide(testInstanceStoreLayer),
|
|
Effect.provide(CrossSpawnSpawner.defaultLayer),
|
|
)
|
|
|
|
const withGlobalConfigDir = <A, E, R>(dir: string, effect: Effect.Effect<A, E, R>) =>
|
|
Effect.acquireUseRelease(
|
|
Effect.gen(function* () {
|
|
const previous = Global.Path.config
|
|
;(Global.Path as { config: string }).config = dir
|
|
yield* clearEffect(true)
|
|
return previous
|
|
}),
|
|
() => effect,
|
|
(previous) =>
|
|
Effect.gen(function* () {
|
|
;(Global.Path as { config: string }).config = previous
|
|
yield* clearEffect(true)
|
|
}),
|
|
)
|
|
|
|
const withGlobalConfig = <A, E, R>(
|
|
input: { config?: object; name?: string },
|
|
fn: (input: { dir: string }) => Effect.Effect<A, E, R>,
|
|
) =>
|
|
Effect.gen(function* () {
|
|
const dir = yield* tmpdirScoped()
|
|
if (input.config) yield* writeConfigEffect(dir, schemaConfig(input.config), input.name)
|
|
return yield* withGlobalConfigDir(dir, fn({ dir }))
|
|
})
|
|
|
|
const withConfigTree = <A, E, R>(
|
|
input: { global?: object; project?: object; local?: object },
|
|
effect: Effect.Effect<A, E, R>,
|
|
) =>
|
|
Effect.gen(function* () {
|
|
const root = yield* tmpdirScoped()
|
|
const global = yield* tmpdirScoped()
|
|
const directory = path.join(root, "project")
|
|
yield* Effect.all(
|
|
[
|
|
input.global ? writeConfigEffect(global, schemaConfig(input.global)) : undefined,
|
|
input.project ? writeConfigEffect(directory, schemaConfig(input.project)) : undefined,
|
|
input.local ? writeConfigEffect(path.join(directory, ".kilo"), schemaConfig(input.local)) : undefined, // kilocode_change
|
|
].filter((effect): effect is Effect.Effect<void, FSUtil.Error, FSUtil.Service> => effect !== undefined),
|
|
{ concurrency: "unbounded" },
|
|
)
|
|
return yield* withGlobalConfigDir(global, withInstanceDir(directory, effect))
|
|
})
|
|
|
|
const wellKnown = (input: {
|
|
authUrl?: string
|
|
config?: unknown
|
|
remoteConfig?: { url: string; headers?: Record<string, string> }
|
|
remote?: unknown
|
|
wellKnown?: unknown
|
|
}) => {
|
|
const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
|
|
const client = remoteConfigClient({
|
|
seen,
|
|
wellKnown: input.wellKnown ?? {
|
|
...(input.config !== undefined ? { config: input.config } : {}),
|
|
...(input.remoteConfig !== undefined ? { remote_config: input.remoteConfig } : {}),
|
|
},
|
|
remote: input.remote,
|
|
})
|
|
return {
|
|
seen,
|
|
it: configIt({ auth: wellKnownAuth(input.authUrl ?? "https://example.com"), client }),
|
|
}
|
|
}
|
|
|
|
function withProcessEnv<A, E, R>(key: string, value: string | undefined, effect: Effect.Effect<A, E, R>) {
|
|
return withProcessEnvs({ [key]: value }, effect)
|
|
}
|
|
|
|
function withProcessEnvs<A, E, R>(entries: Record<string, string | undefined>, effect: Effect.Effect<A, E, R>) {
|
|
return Effect.acquireUseRelease(
|
|
Effect.sync(() => {
|
|
const originals: Record<string, string | undefined> = {}
|
|
for (const [key, value] of Object.entries(entries)) {
|
|
originals[key] = process.env[key]
|
|
if (value === undefined) delete process.env[key]
|
|
else process.env[key] = value
|
|
}
|
|
return originals
|
|
}),
|
|
() => effect,
|
|
(originals) =>
|
|
Effect.sync(() => {
|
|
for (const [key, original] of Object.entries(originals)) {
|
|
if (original !== undefined) process.env[key] = original
|
|
else delete process.env[key]
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
async function check(map: (dir: string) => string) {
|
|
if (process.platform !== "win32") return
|
|
await using globalTmp = await tmpdir()
|
|
await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
|
|
const prev = Global.Path.config
|
|
;(Global.Path as { config: string }).config = globalTmp.path
|
|
await clear()
|
|
try {
|
|
await writeConfig(globalTmp.path, {
|
|
$schema: "https://opencode.ai/config.json",
|
|
snapshot: false,
|
|
})
|
|
await withTestInstance({
|
|
directory: map(tmp.path),
|
|
fn: async (ctx) => {
|
|
const cfg = await load(ctx)
|
|
expect(cfg.snapshot).toBe(true)
|
|
expect(ctx.directory).toBe(Filesystem.resolve(tmp.path))
|
|
expect(ctx.project.id).not.toBe(ProjectV2.ID.global)
|
|
},
|
|
})
|
|
} finally {
|
|
await InstanceRuntime.disposeAllInstances()
|
|
;(Global.Path as { config: string }).config = prev
|
|
await clear()
|
|
}
|
|
}
|
|
|
|
it.instance("loads config with defaults when no files exist", () =>
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
expect(config.username).toBeDefined()
|
|
}),
|
|
)
|
|
|
|
it.instance("falls back to generic username when system user info is unavailable", () =>
|
|
Effect.gen(function* () {
|
|
const userInfo = spyOn(os, "userInfo").mockImplementation(() => {
|
|
throw Object.assign(new Error("missing passwd entry"), { code: "ENOENT" })
|
|
})
|
|
try {
|
|
const config = yield* Config.use.get()
|
|
expect(config.username).toBe("user")
|
|
} finally {
|
|
userInfo.mockRestore()
|
|
}
|
|
}),
|
|
)
|
|
|
|
it.effect("creates global jsonc config with schema when no global configs exist", () =>
|
|
withGlobalConfig({}, ({ dir }) =>
|
|
Effect.gen(function* () {
|
|
yield* Config.use.get().pipe(provideInstanceEffect(dir))
|
|
|
|
const content = yield* FSUtil.use.readFileString(path.join(dir, "kilo.jsonc")) // kilocode_change
|
|
expect(content).toContain('"$schema": "https://app.kilo.ai/config.json"') // kilocode_change
|
|
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
|
|
),
|
|
)
|
|
|
|
it.effect("does not create global config when KILO_CONFIG_DIR is set", () =>
|
|
Effect.gen(function* () {
|
|
const custom = yield* tmpdirScoped()
|
|
yield* withGlobalConfig({}, ({ dir }) =>
|
|
withProcessEnv(
|
|
"KILO_CONFIG_DIR",
|
|
custom,
|
|
Effect.gen(function* () {
|
|
yield* Config.use.get().pipe(provideInstanceEffect(dir))
|
|
|
|
expect(yield* FSUtil.use.existsSafe(path.join(dir, "opencode.jsonc"))).toBe(false)
|
|
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
|
|
),
|
|
)
|
|
}),
|
|
)
|
|
|
|
it.instance("loads JSON config file", () =>
|
|
Effect.gen(function* () {
|
|
// kilocode_change start
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json",
|
|
model: "test/model",
|
|
username: "testuser",
|
|
})
|
|
// kilocode_change end
|
|
const config = yield* Config.use.get()
|
|
expect(config.model).toBe("test/model")
|
|
expect(config.username).toBe("testuser")
|
|
}),
|
|
)
|
|
|
|
// kilocode_change start
|
|
it.instance("preserves Kilo provider free model metadata", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json",
|
|
model: "kilo/free-e2e",
|
|
provider: {
|
|
kilo: {
|
|
models: {
|
|
"free-e2e": {
|
|
id: "free-e2e",
|
|
isFree: true,
|
|
ai_sdk_provider: "openai-compatible",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
const config = yield* Config.use.get()
|
|
const model = config.provider?.kilo?.models?.["free-e2e"]
|
|
expect(model?.isFree).toBe(true)
|
|
expect(model?.ai_sdk_provider).toBe("openai-compatible")
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
it.instance(
|
|
"loads shell config field",
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
expect(config.shell).toBe("bash")
|
|
}),
|
|
{ config: { shell: "bash" } },
|
|
)
|
|
|
|
it.instance("updates config and preserves empty shell sentinel", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
// kilocode_change - upstream hardcodes project config to config.json; Kilo writes to kilo.json
|
|
yield* writeConfigEffect(test.directory, { $schema: "https://opencode.ai/config.json", shell: "bash" })
|
|
|
|
yield* Config.Service.use((svc) => svc.update(ConfigParse.schema(ConfigV1.Info, { shell: "" }, "test:config")))
|
|
|
|
const writtenConfig = yield* FSUtil.use.readJson(path.join(test.directory, "kilo.json")) // kilocode_change
|
|
expect(writtenConfig).toMatchObject({ shell: "" })
|
|
}),
|
|
)
|
|
|
|
it.effect("updates global config and omits empty shell key in json", () =>
|
|
withGlobalConfig({ config: { shell: "bash" } }, ({ dir }) =>
|
|
Effect.gen(function* () {
|
|
yield* Config.use.updateGlobal({ shell: "" })
|
|
|
|
const writtenConfig = yield* FSUtil.use.readJson(path.join(dir, "kilo.json")) // kilocode_change
|
|
expect(writtenConfig).not.toHaveProperty("shell")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.effect("updates global config and omits empty shell key in jsonc", () =>
|
|
withGlobalConfig({ config: { shell: "bash", model: "test/model" }, name: "opencode.jsonc" }, ({ dir }) =>
|
|
Effect.gen(function* () {
|
|
yield* Config.use.updateGlobal({ shell: "" })
|
|
|
|
const file = path.join(dir, "opencode.jsonc")
|
|
const writtenConfig = yield* FSUtil.use.readFileString(file)
|
|
const parsed = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(writtenConfig, file), file)
|
|
expect(writtenConfig).not.toContain('"shell"')
|
|
expect(parsed.shell).toBeUndefined()
|
|
expect(parsed.model).toBe("test/model")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.instance(
|
|
"loads formatter boolean config",
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
expect(config.formatter).toBe(true)
|
|
}),
|
|
{ config: { formatter: true } },
|
|
)
|
|
|
|
it.instance(
|
|
"loads lsp boolean config",
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
expect(config.lsp).toBe(true)
|
|
}),
|
|
{ config: { lsp: true } },
|
|
)
|
|
|
|
test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
|
|
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
|
|
await check((dir) => {
|
|
const drive = dir[0].toLowerCase()
|
|
const rest = dir.slice(2).replaceAll("\\", "/")
|
|
return `/${drive}${rest}`
|
|
})
|
|
})
|
|
|
|
test("loads project config from Cygwin paths on Windows", async () => {
|
|
await check((dir) => {
|
|
const drive = dir[0].toLowerCase()
|
|
const rest = dir.slice(2).replaceAll("\\", "/")
|
|
return `/cygdrive/${drive}${rest}`
|
|
})
|
|
})
|
|
|
|
it.instance("ignores legacy tui keys in opencode config", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://opencode.ai/config.json",
|
|
model: "test/model",
|
|
theme: "legacy",
|
|
tui: { scroll_speed: 4 },
|
|
})
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.model).toBe("test/model")
|
|
expect((config as Record<string, unknown>).theme).toBeUndefined()
|
|
expect((config as Record<string, unknown>).tui).toBeUndefined()
|
|
}),
|
|
)
|
|
|
|
it.instance("loads JSONC config file", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* FSUtil.use.writeWithDirs(
|
|
// kilocode_change start
|
|
path.join(test.directory, "kilo.jsonc"),
|
|
`{
|
|
// This is a comment
|
|
"$schema": "https://app.kilo.ai/config.json",
|
|
"model": "test/model",
|
|
"username": "testuser"
|
|
}`,
|
|
// kilocode_change end
|
|
)
|
|
const config = yield* Config.use.get()
|
|
expect(config.model).toBe("test/model")
|
|
expect(config.username).toBe("testuser")
|
|
}),
|
|
)
|
|
|
|
it.instance("jsonc overrides json in the same directory", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(
|
|
test.directory,
|
|
{
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
model: "base",
|
|
username: "base",
|
|
},
|
|
"kilo.jsonc", // kilocode_change
|
|
)
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
model: "override",
|
|
})
|
|
const config = yield* Config.use.get()
|
|
expect(config.model).toBe("base")
|
|
expect(config.username).toBe("base")
|
|
}),
|
|
)
|
|
|
|
// kilocode_change start
|
|
it.instance("prefers .kilo directory config over legacy .kilocode", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(path.join(test.directory, ".kilocode"), {
|
|
$schema: "https://app.kilo.ai/config.json",
|
|
model: "legacy/model",
|
|
})
|
|
yield* writeConfigEffect(path.join(test.directory, ".kilo"), {
|
|
$schema: "https://app.kilo.ai/config.json",
|
|
model: "new/model",
|
|
})
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.model).toBe("new/model")
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
// kilocode_change start - project config is untrusted: {env:} rejected; {file:} confined to the project root
|
|
it.instance("rejects environment variable substitution in project config", () =>
|
|
withProcessEnv(
|
|
"TEST_VAR",
|
|
"test-user",
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json",
|
|
username: "{env:TEST_VAR}",
|
|
})
|
|
const config = yield* Config.use.get()
|
|
expect(config.username).not.toBe("test-user")
|
|
const issues = yield* Config.Service.use((svc) => svc.warnings())
|
|
expect(issues.length).toBeGreaterThan(0)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.instance("allows {file:} that stays inside the project root", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* FSUtil.use.writeWithDirs(path.join(test.directory, "included.txt"), "in-project")
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json",
|
|
username: "{file:included.txt}",
|
|
})
|
|
const config = yield* Config.use.get()
|
|
expect(config.username).toBe("in-project")
|
|
}),
|
|
)
|
|
|
|
it.instance("rejects {file:} that reads an absolute path from project config", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json",
|
|
username: "{file:/etc/passwd}",
|
|
})
|
|
const config = yield* Config.use.get()
|
|
expect(config.username ?? "").not.toContain("root:")
|
|
}),
|
|
)
|
|
|
|
it.instance("rejects {file:} that escapes the project root with parent directories", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const outside = path.join(path.dirname(test.directory), "secret.txt")
|
|
yield* FSUtil.use.writeWithDirs(outside, "outside-secret")
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json",
|
|
username: "{file:../secret.txt}",
|
|
})
|
|
const config = yield* Config.use.get()
|
|
expect(config.username).not.toBe("outside-secret")
|
|
}),
|
|
)
|
|
|
|
it.instance("rejects {file:} that escapes the project root through a symlink", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const outside = path.join(path.dirname(test.directory), "secret.txt")
|
|
const link = path.join(test.directory, "secret-link")
|
|
yield* FSUtil.use.writeWithDirs(outside, "outside-secret")
|
|
yield* Effect.promise(() => fs.symlink(outside, link))
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json",
|
|
username: "{file:secret-link}",
|
|
})
|
|
const config = yield* Config.use.get()
|
|
expect(config.username).not.toBe("outside-secret")
|
|
}),
|
|
)
|
|
|
|
it.instance("blocks provider apiKey {file:} exfiltration that escapes the project root", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const outside = path.join(path.dirname(test.directory), "creds.txt")
|
|
yield* FSUtil.use.writeWithDirs(outside, "leaked-credential")
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json",
|
|
provider: {
|
|
"openai-compatible": {
|
|
options: { baseURL: "http://127.0.0.1:4444/v1", apiKey: "{file:../creds.txt}" },
|
|
models: { "test-model": { name: "Test Model" } },
|
|
},
|
|
},
|
|
})
|
|
const config = yield* Config.use.get()
|
|
expect(JSON.stringify(config.provider ?? {})).not.toContain("leaked-credential")
|
|
}),
|
|
)
|
|
|
|
it.instance("still allows global config to read absolute files", () =>
|
|
withGlobalConfig({}, ({ dir }) =>
|
|
Effect.gen(function* () {
|
|
const secret = path.join(dir, "secret.txt")
|
|
yield* FSUtil.use.writeWithDirs(secret, "global-secret")
|
|
yield* writeConfigEffect(dir, {
|
|
$schema: "https://app.kilo.ai/config.json",
|
|
username: `{file:${secret}}`,
|
|
})
|
|
const config = yield* Config.use.get()
|
|
expect(config.username).toBe("global-secret")
|
|
}),
|
|
),
|
|
)
|
|
// kilocode_change end
|
|
|
|
const accountTokenIt = configIt({
|
|
account: Layer.mock(Account.Service)({
|
|
active: () =>
|
|
Effect.succeed(
|
|
Option.some({
|
|
id: AccountID.make("account-1"),
|
|
email: "user@example.com",
|
|
url: "https://control.example.com",
|
|
active_org_id: OrgID.make("org-1"),
|
|
}),
|
|
),
|
|
activeOrg: () =>
|
|
Effect.succeed(
|
|
Option.some({
|
|
account: {
|
|
id: AccountID.make("account-1"),
|
|
email: "user@example.com",
|
|
url: "https://control.example.com",
|
|
active_org_id: OrgID.make("org-1"),
|
|
},
|
|
org: {
|
|
id: OrgID.make("org-1"),
|
|
name: "Example Org",
|
|
},
|
|
}),
|
|
),
|
|
config: () =>
|
|
Effect.succeed(
|
|
Option.some({
|
|
provider: { opencode: { options: { apiKey: "{env:KILO_CONSOLE_TOKEN}" } } },
|
|
}),
|
|
),
|
|
token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
|
|
}),
|
|
})
|
|
|
|
accountTokenIt.instance("resolves env templates in account config with account token", () =>
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
|
|
}),
|
|
)
|
|
|
|
// kilocode_change start
|
|
it.instance("validates config schema and reports warning on invalid fields", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json",
|
|
invalid_field: "should cause error",
|
|
})
|
|
// invalid schema surfaces as warnings, not a throw
|
|
yield* Config.use.get()
|
|
const issues = yield* Config.Service.use((svc) => svc.warnings())
|
|
expect(issues.length).toBeGreaterThan(0)
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
// kilocode_change start
|
|
it.instance("reports warning for invalid JSON", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* FSUtil.use.writeWithDirs(path.join(test.directory, "kilo.json"), "{ invalid json }")
|
|
yield* Config.use.get()
|
|
const issues = yield* Config.Service.use((svc) => svc.warnings())
|
|
expect(issues.length).toBeGreaterThan(0)
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
it.instance("handles agent configuration", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
agent: {
|
|
test_agent: {
|
|
model: "test/model",
|
|
temperature: 0.7,
|
|
description: "test agent",
|
|
},
|
|
},
|
|
})
|
|
const config = yield* Config.use.get()
|
|
expect(config.agent?.["test_agent"]).toEqual(
|
|
expect.objectContaining({
|
|
model: "test/model",
|
|
temperature: 0.7,
|
|
description: "test agent",
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
|
|
it.instance("treats agent variant as model-scoped setting (not provider option)", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
agent: {
|
|
test_agent: {
|
|
model: "openai/gpt-5.2",
|
|
variant: "xhigh",
|
|
max_tokens: 123,
|
|
},
|
|
},
|
|
})
|
|
const config = yield* Config.use.get()
|
|
const agent = config.agent?.["test_agent"]
|
|
|
|
expect(agent?.variant).toBe("xhigh")
|
|
expect(agent?.options).toMatchObject({
|
|
max_tokens: 123,
|
|
})
|
|
expect(agent?.options).not.toHaveProperty("variant")
|
|
}),
|
|
)
|
|
|
|
it.instance("handles command configuration", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
command: {
|
|
test_command: {
|
|
template: "test template",
|
|
description: "test command",
|
|
agent: "test_agent",
|
|
},
|
|
},
|
|
})
|
|
const config = yield* Config.use.get()
|
|
expect(config.command?.["test_command"]).toEqual({
|
|
template: "test template",
|
|
description: "test command",
|
|
agent: "test_agent",
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.instance("migrates autoshare to share field", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
autoshare: true,
|
|
})
|
|
const config = yield* Config.use.get()
|
|
expect(config.share).toBe("auto")
|
|
expect(config.autoshare).toBe(true)
|
|
}),
|
|
)
|
|
|
|
it.instance("migrates mode field to agent field", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
mode: {
|
|
test_mode: {
|
|
model: "test/model",
|
|
temperature: 0.5,
|
|
},
|
|
},
|
|
})
|
|
const config = yield* Config.use.get()
|
|
expect(config.agent?.["test_mode"]).toEqual({
|
|
model: "test/model",
|
|
temperature: 0.5,
|
|
mode: "primary",
|
|
options: {},
|
|
permission: {},
|
|
})
|
|
}),
|
|
)
|
|
|
|
// kilocode_change start
|
|
it.instance("loads config from .kilo directory", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* FSUtil.use.writeWithDirs(
|
|
path.join(test.directory, ".kilo", "agent", "test.md"), // kilocode_change
|
|
`---
|
|
model: test/model
|
|
---
|
|
Test agent prompt`,
|
|
)
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.agent?.["test"]).toEqual(
|
|
expect.objectContaining({
|
|
name: "test",
|
|
model: "test/model",
|
|
prompt: "Test agent prompt",
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
it.instance("agent markdown permission config preserves user key order", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* FSUtil.use.writeWithDirs(
|
|
path.join(test.directory, ".kilo", "agent", "ordered.md"), // kilocode_change
|
|
`---
|
|
permission:
|
|
bash: allow
|
|
"*": deny
|
|
edit: ask
|
|
---
|
|
Ordered permissions`,
|
|
)
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(Object.keys(config.agent?.ordered?.permission ?? {})).toEqual(["bash", "*", "edit"])
|
|
}),
|
|
)
|
|
|
|
// kilocode_change start
|
|
it.instance("loads agents from .kilo/agents (plural)", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* FSUtil.use.writeWithDirs(
|
|
path.join(test.directory, ".kilo", "agents", "helper.md"), // kilocode_change
|
|
`---
|
|
model: test/model
|
|
mode: subagent
|
|
---
|
|
Helper agent prompt`,
|
|
)
|
|
|
|
yield* FSUtil.use.writeWithDirs(
|
|
path.join(test.directory, ".kilo", "agents", "nested", "child.md"), // kilocode_change
|
|
`---
|
|
model: test/model
|
|
mode: subagent
|
|
---
|
|
Nested agent prompt`,
|
|
)
|
|
|
|
const config = yield* Config.use.get()
|
|
|
|
expect(config.agent?.["helper"]).toMatchObject({
|
|
name: "helper",
|
|
model: "test/model",
|
|
mode: "subagent",
|
|
prompt: "Helper agent prompt",
|
|
})
|
|
|
|
expect(config.agent?.["nested/child"]).toMatchObject({
|
|
name: "nested/child",
|
|
model: "test/model",
|
|
mode: "subagent",
|
|
prompt: "Nested agent prompt",
|
|
})
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
// kilocode_change start
|
|
it.instance("loads commands from .kilo/command (singular)", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* FSUtil.use.writeWithDirs(
|
|
path.join(test.directory, ".kilo", "command", "hello.md"), // kilocode_change
|
|
`---
|
|
description: Test command
|
|
---
|
|
Hello from singular command`,
|
|
)
|
|
|
|
yield* FSUtil.use.writeWithDirs(
|
|
path.join(test.directory, ".kilo", "command", "nested", "child.md"), // kilocode_change
|
|
`---
|
|
description: Nested command
|
|
---
|
|
Nested command template`,
|
|
)
|
|
|
|
const config = yield* Config.use.get()
|
|
|
|
expect(config.command?.["hello"]).toEqual({
|
|
description: "Test command",
|
|
template: "Hello from singular command",
|
|
})
|
|
|
|
expect(config.command?.["nested/child"]).toEqual({
|
|
description: "Nested command",
|
|
template: "Nested command template",
|
|
})
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
// kilocode_change start
|
|
it.instance("loads commands from .kilo/commands (plural)", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* FSUtil.use.writeWithDirs(
|
|
path.join(test.directory, ".kilo", "commands", "hello.md"), // kilocode_change
|
|
`---
|
|
description: Test command
|
|
---
|
|
Hello from plural commands`,
|
|
)
|
|
|
|
yield* FSUtil.use.writeWithDirs(
|
|
path.join(test.directory, ".kilo", "commands", "nested", "child.md"), // kilocode_change
|
|
`---
|
|
description: Nested command
|
|
---
|
|
Nested command template`,
|
|
)
|
|
|
|
const config = yield* Config.use.get()
|
|
|
|
expect(config.command?.["hello"]).toEqual({
|
|
description: "Test command",
|
|
template: "Hello from plural commands",
|
|
})
|
|
|
|
expect(config.command?.["nested/child"]).toEqual({
|
|
description: "Nested command",
|
|
template: "Nested command template",
|
|
})
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
// kilocode_change start
|
|
it.instance("prefers .kilo commands over legacy .kilocode commands", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* FSUtil.use.writeWithDirs(
|
|
path.join(test.directory, ".kilocode", "command", "hello.md"),
|
|
`---
|
|
description: Legacy command
|
|
---
|
|
Hello from legacy command`,
|
|
)
|
|
yield* FSUtil.use.writeWithDirs(
|
|
path.join(test.directory, ".kilo", "command", "hello.md"),
|
|
`---
|
|
description: New command
|
|
---
|
|
Hello from new command`,
|
|
)
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.command?.["hello"]).toEqual({
|
|
description: "New command",
|
|
template: "Hello from new command",
|
|
})
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
it.instance("updates config and writes to file", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* Config.Service.use((svc) =>
|
|
svc.update(ConfigParse.schema(ConfigV1.Info, { model: "updated/model" }, "test:config")),
|
|
)
|
|
|
|
const writtenConfig = yield* FSUtil.use.readJson(
|
|
path.join(test.directory, ".kilo", "kilo.jsonc"), // kilocode_change
|
|
)
|
|
expect(writtenConfig).toMatchObject({ model: "updated/model" })
|
|
}),
|
|
)
|
|
|
|
it.instance("gets config directories", () =>
|
|
Effect.gen(function* () {
|
|
const dirs = yield* Config.use.directories()
|
|
expect(dirs.length).toBeGreaterThanOrEqual(1)
|
|
}),
|
|
)
|
|
|
|
it.effect("does not try to install dependencies in read-only KILO_CONFIG_DIR", () =>
|
|
Effect.gen(function* () {
|
|
if (process.platform === "win32") return
|
|
|
|
const dir = yield* tmpdirScoped()
|
|
const readonly = path.join(dir, "readonly")
|
|
yield* FSUtil.use.ensureDir(readonly)
|
|
yield* FSUtil.use.chmod(readonly, 0o555)
|
|
yield* Effect.addFinalizer(() => FSUtil.use.chmod(readonly, 0o755).pipe(Effect.ignore))
|
|
|
|
yield* withProcessEnv("KILO_CONFIG_DIR", readonly, Config.use.get().pipe(provideInstanceEffect(dir)))
|
|
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
|
|
)
|
|
|
|
it.effect("installs dependencies in writable KILO_CONFIG_DIR", () =>
|
|
Effect.gen(function* () {
|
|
const dir = yield* tmpdirScoped()
|
|
const configDir = path.join(dir, "configdir")
|
|
yield* FSUtil.use.ensureDir(configDir)
|
|
|
|
yield* withProcessEnv(
|
|
"KILO_CONFIG_DIR",
|
|
configDir,
|
|
Config.Service.use((svc) => svc.get().pipe(Effect.andThen(svc.waitForDependencies()))).pipe(
|
|
provideInstanceEffect(dir),
|
|
),
|
|
)
|
|
|
|
expect(yield* FSUtil.use.readFileString(path.join(configDir, ".gitignore"))).toContain("package-lock.json")
|
|
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
|
|
)
|
|
|
|
// Note: deduplication and serialization of npm installs is now handled by the
|
|
// core Npm.Service (via EffectFlock). Those behaviors are tested in the core
|
|
// package's npm tests, not here.
|
|
|
|
it.instance("resolves scoped npm plugins in config", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const pluginDir = path.join(test.directory, "node_modules", "@scope", "plugin")
|
|
yield* FSUtil.use.writeWithDirs(
|
|
path.join(test.directory, "package.json"),
|
|
JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
|
|
)
|
|
yield* FSUtil.use.writeWithDirs(
|
|
path.join(pluginDir, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "@scope/plugin",
|
|
version: "1.0.0",
|
|
type: "module",
|
|
main: "./index.js",
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
yield* FSUtil.use.writeWithDirs(path.join(pluginDir, "index.js"), "export default {}\n")
|
|
yield* writeConfigEffect(test.directory, { plugin: ["@scope/plugin"] })
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.plugin ?? []).toContain("@scope/plugin")
|
|
}),
|
|
)
|
|
|
|
it.effect("merges plugin arrays from global and local configs", () =>
|
|
withConfigTree(
|
|
{
|
|
global: { plugin: ["global-plugin-1", "global-plugin-2"] },
|
|
local: { plugin: ["local-plugin-1"] },
|
|
},
|
|
Effect.gen(function* () {
|
|
const plugins = (yield* Config.use.get()).plugin ?? []
|
|
|
|
expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
|
|
expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true)
|
|
expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
|
|
expect(
|
|
plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin")).length,
|
|
).toBeGreaterThanOrEqual(3)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.effect("global config remains global when project config is disabled", () =>
|
|
withConfigTree(
|
|
{
|
|
global: { model: "global/model", plugin: ["global-plugin"] },
|
|
project: { model: "project/model" },
|
|
local: { model: "local/model" },
|
|
},
|
|
withProcessEnv(
|
|
"KILO_DISABLE_PROJECT_CONFIG",
|
|
"true",
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
expect(config.model).toBe("global/model")
|
|
expect(config.plugin_origins?.find((item) => item.spec === "global-plugin")?.scope).toBe("global")
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.instance("does not error when only custom agent is a subagent", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* FSUtil.use.writeWithDirs(
|
|
path.join(test.directory, ".kilo", "agent", "helper.md"), // kilocode_change
|
|
`---
|
|
model: test/model
|
|
mode: subagent
|
|
---
|
|
Helper subagent prompt`,
|
|
)
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.agent?.["helper"]).toMatchObject({
|
|
name: "helper",
|
|
model: "test/model",
|
|
mode: "subagent",
|
|
prompt: "Helper subagent prompt",
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("merges instructions arrays from global and local configs", () =>
|
|
withConfigTree(
|
|
{
|
|
global: { instructions: ["global-instructions.md", "shared-rules.md"] },
|
|
local: { instructions: ["local-instructions.md"] },
|
|
},
|
|
Effect.gen(function* () {
|
|
expect((yield* Config.use.get()).instructions).toEqual([
|
|
"global-instructions.md",
|
|
"shared-rules.md",
|
|
"local-instructions.md",
|
|
])
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.effect("deduplicates duplicate instructions from global and local configs", () =>
|
|
withConfigTree(
|
|
{
|
|
global: { instructions: ["duplicate.md", "global-only.md"] },
|
|
local: { instructions: ["duplicate.md", "local-only.md"] },
|
|
},
|
|
Effect.gen(function* () {
|
|
expect((yield* Config.use.get()).instructions).toEqual(["duplicate.md", "global-only.md", "local-only.md"])
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.effect("deduplicates duplicate plugins from global and local configs", () =>
|
|
withConfigTree(
|
|
{
|
|
global: { plugin: ["duplicate-plugin", "global-plugin-1"] },
|
|
local: { plugin: ["duplicate-plugin", "local-plugin-1"] },
|
|
},
|
|
Effect.gen(function* () {
|
|
const plugins = (yield* Config.use.get()).plugin ?? []
|
|
|
|
expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
|
|
expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
|
|
expect(plugins.filter((p) => p.includes("duplicate-plugin")).length).toBe(1)
|
|
expect(
|
|
plugins.filter(
|
|
(p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
|
|
).length,
|
|
).toBe(3)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.effect("keeps plugin origins aligned with merged plugin list", () =>
|
|
withConfigTree(
|
|
{
|
|
global: { plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"] },
|
|
local: { plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"] },
|
|
},
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
const plugins = config.plugin ?? []
|
|
const origins = config.plugin_origins ?? []
|
|
const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item))
|
|
|
|
expect(names).toContain("shared-plugin@2.0.0")
|
|
expect(names).not.toContain("shared-plugin@1.0.0")
|
|
expect(names).toContain("global-only@1.0.0")
|
|
expect(names).toContain("local-only@1.0.0")
|
|
// kilocode_change start - bundled plugins intentionally have no external plugin origins
|
|
expect(origins.map((item) => item.spec)).toEqual(
|
|
plugins.filter((item) => !isIndexingPlugin(item) && !isAtomicChatPlugin(item)),
|
|
)
|
|
// kilocode_change end
|
|
expect(origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")?.scope).toBe(
|
|
"local",
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
|
|
// Legacy tools migration tests
|
|
|
|
it.instance("migrates legacy tools config to permissions - allow", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
agent: { test: { tools: { bash: true, read: true } } },
|
|
})
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.agent?.["test"]?.permission).toEqual({
|
|
bash: "allow",
|
|
read: "allow",
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.instance("migrates legacy tools config to permissions - deny", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
agent: { test: { tools: { bash: false, webfetch: false } } },
|
|
})
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.agent?.["test"]?.permission).toEqual({
|
|
bash: "deny",
|
|
webfetch: "deny",
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.instance("migrates legacy write tool to edit permission", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
agent: { test: { tools: { write: true } } },
|
|
})
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow" })
|
|
}),
|
|
)
|
|
|
|
// Managed settings tests
|
|
// kilocode_change - Note: preload.ts sets KILO_TEST_MANAGED_CONFIG which Global.Path.managedConfig uses
|
|
|
|
it.instance(
|
|
"managed settings override user settings",
|
|
Effect.gen(function* () {
|
|
yield* writeManagedSettingsEffect({
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
model: "managed/model",
|
|
share: "disabled",
|
|
})
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.model).toBe("managed/model")
|
|
expect(config.share).toBe("disabled")
|
|
expect(config.username).toBe("testuser")
|
|
}),
|
|
{ config: { model: "user/model", share: "auto", username: "testuser" } },
|
|
)
|
|
|
|
it.instance(
|
|
"managed settings override project settings",
|
|
Effect.gen(function* () {
|
|
yield* writeManagedSettingsEffect({
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
autoupdate: false,
|
|
disabled_providers: ["openai"],
|
|
})
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.autoupdate).toBe(false)
|
|
expect(config.disabled_providers).toEqual(["openai"])
|
|
}),
|
|
{ config: { autoupdate: true, disabled_providers: [] } },
|
|
)
|
|
|
|
it.instance("managed jsonc settings override managed json settings", () =>
|
|
Effect.gen(function* () {
|
|
yield* writeManagedSettingsEffect({ model: "managed/json" })
|
|
yield* writeManagedSettingsEffect({ model: "managed/jsonc" }, "opencode.jsonc")
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.model).toBe("managed/jsonc")
|
|
}),
|
|
)
|
|
|
|
it.instance(
|
|
"missing managed settings file is not an error",
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
expect(config.model).toBe("user/model")
|
|
}),
|
|
{ config: { model: "user/model" } },
|
|
)
|
|
|
|
it.instance("migrates legacy edit tool to edit permission", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
agent: { test: { tools: { edit: false } } },
|
|
})
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.agent?.["test"]?.permission).toEqual({ edit: "deny" })
|
|
}),
|
|
)
|
|
|
|
it.instance("migrates legacy patch tool to edit permission", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
agent: { test: { tools: { patch: true } } },
|
|
})
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow" })
|
|
}),
|
|
)
|
|
|
|
it.instance("migrates mixed legacy tools config", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
agent: { test: { tools: { bash: true, write: true, read: false, webfetch: true } } },
|
|
})
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.agent?.["test"]?.permission).toEqual({
|
|
bash: "allow",
|
|
edit: "allow",
|
|
read: "deny",
|
|
webfetch: "allow",
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.instance("merges legacy tools with existing permission config", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
agent: { test: { permission: { glob: "allow" }, tools: { bash: true } } },
|
|
})
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.agent?.["test"]?.permission).toEqual({
|
|
glob: "allow",
|
|
bash: "allow",
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.instance("permission config preserves user key order", () =>
|
|
// Permission precedence follows the order users write in config, so parsing
|
|
// must not canonicalise known keys ahead of wildcard or custom keys.
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
// kilocode_change start — isolate from global config to prevent cross-test contamination
|
|
// (migrateBashPermission may write permission.bash to a global config file created by other
|
|
// test files running in parallel, which mergeDeep then prepends to the project permission keys)
|
|
const globalTmp = yield* tmpdirScoped()
|
|
const prev = Global.Path.config
|
|
;(Global.Path as { config: string }).config = globalTmp
|
|
// kilocode_change end
|
|
yield* Effect.addFinalizer(() =>
|
|
Effect.gen(function* () {
|
|
// kilocode_change start
|
|
;(Global.Path as { config: string }).config = prev
|
|
yield* Config.use.invalidate()
|
|
// kilocode_change end
|
|
}),
|
|
)
|
|
yield* Config.use.invalidate()
|
|
yield* writeConfigEffect(
|
|
test.directory,
|
|
{
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
permission: {
|
|
"*": "deny",
|
|
edit: "ask",
|
|
write: "ask",
|
|
external_directory: "ask",
|
|
read: "allow",
|
|
todowrite: "allow",
|
|
"thoughts_*": "allow",
|
|
"reasoning_model_*": "allow",
|
|
"tools_*": "allow",
|
|
"pr_comments_*": "allow",
|
|
},
|
|
},
|
|
"kilo.json", // kilocode_change
|
|
)
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(Object.keys(config.permission!)).toEqual([
|
|
"*",
|
|
"edit",
|
|
"write",
|
|
"external_directory",
|
|
"read",
|
|
"todowrite",
|
|
"thoughts_*",
|
|
"reasoning_model_*",
|
|
"tools_*",
|
|
"pr_comments_*",
|
|
])
|
|
}),
|
|
)
|
|
|
|
test("config parser preserves permission order while rejecting unknown top-level keys", () => {
|
|
const config = ConfigParse.schema(
|
|
ConfigV1.Info,
|
|
{
|
|
permission: {
|
|
bash: "allow",
|
|
"*": "deny",
|
|
edit: "ask",
|
|
},
|
|
},
|
|
"test",
|
|
)
|
|
|
|
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
|
|
try {
|
|
ConfigParse.schema(ConfigV1.Info, { invalid_field: true }, "test")
|
|
throw new Error("expected config parse to fail")
|
|
} catch (err) {
|
|
const error = err as { data?: { issues?: Array<{ code?: string; keys?: string[]; path?: string[] }> } }
|
|
expect(error.data?.issues?.[0]).toMatchObject({ code: "unrecognized_keys", keys: ["invalid_field"], path: [] })
|
|
}
|
|
})
|
|
|
|
// MCP config merging tests
|
|
|
|
// kilocode_change start - regression for `env` alias on local MCP entries
|
|
it.instance("local mcp accepts `env` as an alias for `environment`", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json",
|
|
mcp: {
|
|
context7: {
|
|
type: "local",
|
|
command: ["npx", "-y", "@upstash/context7-mcp"],
|
|
env: { CONTEXT7_API_KEY: "test-key" },
|
|
enabled: true,
|
|
},
|
|
},
|
|
})
|
|
const config = yield* Config.use.get()
|
|
expect(config.mcp?.context7).toEqual({
|
|
type: "local",
|
|
command: ["npx", "-y", "@upstash/context7-mcp"],
|
|
environment: { CONTEXT7_API_KEY: "test-key" },
|
|
enabled: true,
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.instance("local mcp prefers `environment` over `env` when both are present", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json",
|
|
mcp: {
|
|
context7: {
|
|
type: "local",
|
|
command: ["npx", "-y", "@upstash/context7-mcp"],
|
|
environment: { CONTEXT7_API_KEY: "from-environment" },
|
|
env: { CONTEXT7_API_KEY: "from-env" },
|
|
},
|
|
},
|
|
})
|
|
const config = yield* Config.use.get()
|
|
expect(config.mcp?.context7).toEqual({
|
|
type: "local",
|
|
command: ["npx", "-y", "@upstash/context7-mcp"],
|
|
environment: { CONTEXT7_API_KEY: "from-environment" },
|
|
})
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
it.instance("project config can override MCP server enabled status", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
// kilocode_change - base config in .json, override in .jsonc (jsonc loads second and wins)
|
|
// Simulates a base config (like from remote .well-known) with disabled MCP.
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
mcp: {
|
|
jira: {
|
|
type: "remote",
|
|
url: "https://jira.example.com/mcp",
|
|
enabled: false,
|
|
},
|
|
wiki: {
|
|
type: "remote",
|
|
url: "https://wiki.example.com/mcp",
|
|
enabled: false,
|
|
},
|
|
},
|
|
})
|
|
// Project config enables just jira.
|
|
yield* writeConfigEffect(
|
|
test.directory,
|
|
{
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
mcp: {
|
|
jira: {
|
|
type: "remote",
|
|
url: "https://jira.example.com/mcp",
|
|
enabled: true,
|
|
},
|
|
},
|
|
},
|
|
"kilo.jsonc", // kilocode_change
|
|
)
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.mcp?.jira).toEqual({
|
|
type: "remote",
|
|
url: "https://jira.example.com/mcp",
|
|
enabled: true,
|
|
})
|
|
expect(config.mcp?.wiki).toEqual({
|
|
type: "remote",
|
|
url: "https://wiki.example.com/mcp",
|
|
enabled: false,
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.instance("MCP config deep merges preserving base config properties", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
// kilocode_change - base config in .json, override in .jsonc (jsonc loads second and wins)
|
|
// kilocode_change - Base config with full MCP definition
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
mcp: {
|
|
myserver: {
|
|
type: "remote",
|
|
url: "https://myserver.example.com/mcp",
|
|
enabled: false,
|
|
headers: {
|
|
"X-Custom-Header": "value",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
// kilocode_change - Override just enables it, should preserve other properties
|
|
yield* writeConfigEffect(
|
|
test.directory,
|
|
{
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
mcp: {
|
|
myserver: {
|
|
type: "remote",
|
|
url: "https://myserver.example.com/mcp",
|
|
enabled: true,
|
|
},
|
|
},
|
|
},
|
|
"kilo.jsonc", // kilocode_change
|
|
)
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.mcp?.myserver).toEqual({
|
|
type: "remote",
|
|
url: "https://myserver.example.com/mcp",
|
|
enabled: true,
|
|
headers: {
|
|
"X-Custom-Header": "value",
|
|
},
|
|
})
|
|
}),
|
|
)
|
|
|
|
// kilocode_change start
|
|
it.instance("local .kilo config can override MCP from project config", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, {
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
mcp: {
|
|
docs: {
|
|
type: "remote",
|
|
url: "https://docs.example.com/mcp",
|
|
enabled: false,
|
|
},
|
|
},
|
|
})
|
|
yield* writeConfigEffect(
|
|
path.join(test.directory, ".kilo"), // kilocode_change
|
|
{
|
|
$schema: "https://app.kilo.ai/config.json", // kilocode_change
|
|
mcp: {
|
|
docs: {
|
|
type: "remote",
|
|
url: "https://docs.example.com/mcp",
|
|
enabled: true,
|
|
},
|
|
},
|
|
},
|
|
"kilo.json", // kilocode_change
|
|
)
|
|
|
|
const config = yield* Config.use.get()
|
|
expect(config.mcp?.docs?.enabled).toBe(true)
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
const remoteProjectOverride = wellKnown({
|
|
config: {
|
|
mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
|
|
},
|
|
})
|
|
|
|
remoteProjectOverride.it.instance(
|
|
"project config overrides remote well-known config",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
expect(remoteProjectOverride.seen.wellKnown).toBe("https://example.com/.well-known/opencode")
|
|
expect(config.mcp?.jira?.enabled).toBe(true)
|
|
}),
|
|
{
|
|
git: true,
|
|
config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
|
|
},
|
|
)
|
|
|
|
const trailingSlashWellKnown = wellKnown({
|
|
authUrl: "https://example.com/",
|
|
config: {
|
|
mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } },
|
|
},
|
|
})
|
|
|
|
trailingSlashWellKnown.it.instance("wellknown URL with trailing slash is normalized", () =>
|
|
Effect.gen(function* () {
|
|
yield* Config.use.get()
|
|
expect(trailingSlashWellKnown.seen.wellKnown).toBe("https://example.com/.well-known/opencode")
|
|
}),
|
|
)
|
|
|
|
test("remote well-known config can use FetchHttpClient layer", async () => {
|
|
let fetchedUrl: string | undefined
|
|
const server = Bun.serve({
|
|
port: 0,
|
|
fetch: (request) => {
|
|
fetchedUrl = request.url
|
|
return new Response(
|
|
JSON.stringify({
|
|
config: {
|
|
mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } },
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
)
|
|
},
|
|
})
|
|
|
|
try {
|
|
await provideTmpdirInstance(
|
|
() =>
|
|
Config.Service.use((svc) =>
|
|
Effect.gen(function* () {
|
|
const config = yield* svc.get()
|
|
expect(fetchedUrl).toBe(`${server.url.origin}/.well-known/opencode`)
|
|
expect(config.mcp?.jira?.enabled).toBe(true)
|
|
}),
|
|
),
|
|
{ git: true },
|
|
).pipe(
|
|
Effect.scoped,
|
|
Effect.provide(
|
|
Layer.mergeAll(
|
|
Config.layer.pipe(
|
|
Layer.provide(Git.defaultLayer), // kilocode_change
|
|
Layer.provide(testFlock),
|
|
Layer.provide(FSUtil.defaultLayer),
|
|
Layer.provide(Env.defaultLayer),
|
|
Layer.provide(wellKnownAuth(server.url.origin)),
|
|
Layer.provide(AccountTest.empty),
|
|
Layer.provideMerge(infra),
|
|
Layer.provide(NpmTest.noop),
|
|
Layer.provide(FetchHttpClient.layer),
|
|
),
|
|
testInstanceStoreLayer,
|
|
),
|
|
),
|
|
Effect.runPromise,
|
|
)
|
|
} finally {
|
|
await server.stop(true)
|
|
}
|
|
})
|
|
|
|
const templatedHeaderWellKnown = wellKnown({
|
|
remoteConfig: {
|
|
url: "https://config.example.com/opencode.json",
|
|
headers: { Authorization: "Bearer {env:TEST_TOKEN}" },
|
|
},
|
|
remote: {
|
|
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
|
|
},
|
|
})
|
|
|
|
templatedHeaderWellKnown.it.instance("wellknown remote_config supports templated env vars in headers", () =>
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
expect(templatedHeaderWellKnown.seen.wellKnown).toBe("https://example.com/.well-known/opencode")
|
|
expect(templatedHeaderWellKnown.seen.remote).toBe("https://config.example.com/opencode.json")
|
|
expect(templatedHeaderWellKnown.seen.authorization).toBe("Bearer test-token")
|
|
expect(config.mcp?.confluence?.enabled).toBe(true)
|
|
}),
|
|
)
|
|
|
|
const remotePrecedenceWellKnown = wellKnown({
|
|
config: {
|
|
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: false } },
|
|
},
|
|
remoteConfig: { url: "https://config.example.com/{env:TEST_TOKEN}/opencode.json" },
|
|
remote: {
|
|
config: { mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } } },
|
|
},
|
|
})
|
|
|
|
remotePrecedenceWellKnown.it.instance(
|
|
"wellknown remote_config url tokens and nested config override embedded config",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
expect(remotePrecedenceWellKnown.seen.remote).toBe("https://config.example.com/test-token/opencode.json")
|
|
expect(config.mcp?.confluence?.enabled).toBe(true)
|
|
}),
|
|
)
|
|
|
|
const envIsolationWellKnown = wellKnown({
|
|
remoteConfig: {
|
|
url: "https://config.example.com/opencode.json",
|
|
headers: { Authorization: "Bearer {env:TEST_TOKEN}" },
|
|
},
|
|
remote: {
|
|
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
|
|
},
|
|
})
|
|
|
|
envIsolationWellKnown.it.instance(
|
|
"wellknown token env substitution does not mutate process env",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
process.env.TEST_TOKEN = "preexisting-token"
|
|
const config = yield* Config.use.get()
|
|
// The well-known header (trusted source) resolves the auth-provided token...
|
|
expect(envIsolationWellKnown.seen.authorization).toBe("Bearer test-token")
|
|
// ...but the project config token is untrusted and must not be substituted.
|
|
expect(config.username).not.toBe("test-token")
|
|
// ...and the auth env used for substitution must not leak into the real process env.
|
|
expect(process.env.TEST_TOKEN).toBe("preexisting-token")
|
|
}),
|
|
{ git: true, config: { username: "{env:TEST_TOKEN}" } },
|
|
)
|
|
|
|
const nullConfigWellKnown = wellKnown({
|
|
wellKnown: {
|
|
config: null,
|
|
remote_config: { url: "https://config.example.com/opencode.json" },
|
|
},
|
|
remote: {
|
|
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
|
|
},
|
|
})
|
|
|
|
nullConfigWellKnown.it.instance("wellknown config null is treated as absent", () =>
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
expect(nullConfigWellKnown.seen.remote).toBe("https://config.example.com/opencode.json")
|
|
expect(config.mcp?.confluence?.enabled).toBe(true)
|
|
}),
|
|
)
|
|
|
|
const invalidRemoteWellKnown = wellKnown({
|
|
remoteConfig: { url: "https://config.example.com/opencode.json" },
|
|
remote: "not an object",
|
|
})
|
|
|
|
invalidRemoteWellKnown.it.instance("wellknown remote_config rejects non-object config responses", () =>
|
|
Effect.gen(function* () {
|
|
const exit = yield* Config.use.get().pipe(Effect.exit)
|
|
expect(invalidRemoteWellKnown.seen.remote).toBe("https://config.example.com/opencode.json")
|
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
}),
|
|
)
|
|
|
|
describe("resolvePluginSpec", () => {
|
|
test("keeps package specs unchanged", async () => {
|
|
await using tmp = await tmpdir()
|
|
const file = path.join(tmp.path, "kilo.json") // kilocode_change
|
|
expect(await ConfigPlugin.resolvePluginSpec("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3")
|
|
expect(await ConfigPlugin.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
|
|
})
|
|
|
|
test("resolves windows-style relative plugin directory specs", async () => {
|
|
if (process.platform !== "win32") return
|
|
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const plugin = path.join(dir, "plugin")
|
|
await fs.mkdir(plugin, { recursive: true })
|
|
await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
|
|
},
|
|
})
|
|
|
|
const file = path.join(tmp.path, "opencode.json")
|
|
const hit = await ConfigPlugin.resolvePluginSpec(".\\plugin", file)
|
|
expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
|
|
})
|
|
|
|
test("resolves relative file plugin paths to file urls", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Filesystem.write(path.join(dir, "plugin.ts"), "export default {}")
|
|
},
|
|
})
|
|
|
|
const file = path.join(tmp.path, "kilo.json") // kilocode_change
|
|
const hit = await ConfigPlugin.resolvePluginSpec("./plugin.ts", file)
|
|
expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href)
|
|
})
|
|
|
|
test("resolves plugin directory paths to directory urls", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const plugin = path.join(dir, "plugin")
|
|
await fs.mkdir(plugin, { recursive: true })
|
|
await Filesystem.writeJson(path.join(plugin, "package.json"), {
|
|
name: "demo-plugin",
|
|
type: "module",
|
|
main: "./index.ts",
|
|
})
|
|
await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
|
|
},
|
|
})
|
|
|
|
const file = path.join(tmp.path, "kilo.json") // kilocode_change
|
|
const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file)
|
|
expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href)
|
|
})
|
|
|
|
test("resolves plugin directories without package.json to index.ts", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const plugin = path.join(dir, "plugin")
|
|
await fs.mkdir(plugin, { recursive: true })
|
|
await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
|
|
},
|
|
})
|
|
|
|
const file = path.join(tmp.path, "opencode.json")
|
|
const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file)
|
|
expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
|
|
})
|
|
})
|
|
|
|
describe("deduplicatePluginOrigins", () => {
|
|
const dedupe = (plugins: ConfigPluginV1.Spec[]) =>
|
|
ConfigPlugin.deduplicatePluginOrigins(
|
|
plugins.map((spec) => ({
|
|
spec,
|
|
source: "",
|
|
scope: "global" as const,
|
|
})),
|
|
).map((item) => item.spec)
|
|
|
|
test("removes duplicates keeping higher priority (later entries)", () => {
|
|
const plugins = ["global-plugin@1.0.0", "shared-plugin@1.0.0", "local-plugin@2.0.0", "shared-plugin@2.0.0"]
|
|
|
|
const result = dedupe(plugins)
|
|
|
|
expect(result).toContain("global-plugin@1.0.0")
|
|
expect(result).toContain("local-plugin@2.0.0")
|
|
expect(result).toContain("shared-plugin@2.0.0")
|
|
expect(result).not.toContain("shared-plugin@1.0.0")
|
|
expect(result.length).toBe(3)
|
|
})
|
|
|
|
test("keeps path plugins separate from package plugins", () => {
|
|
const plugins = ["oh-my-opencode@2.4.3", "file:///project/.kilo/plugin/oh-my-opencode.js"] // kilocode_change
|
|
|
|
const result = dedupe(plugins)
|
|
|
|
expect(result).toEqual(plugins)
|
|
})
|
|
|
|
test("deduplicates direct path plugins by exact spec", () => {
|
|
const plugins = ["file:///project/.kilo/plugin/demo.ts", "file:///project/.kilo/plugin/demo.ts"] // kilocode_change
|
|
|
|
const result = dedupe(plugins)
|
|
|
|
expect(result).toEqual(["file:///project/.kilo/plugin/demo.ts"]) // kilocode_change
|
|
})
|
|
|
|
test("preserves order of remaining plugins", () => {
|
|
const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]
|
|
|
|
const result = dedupe(plugins)
|
|
|
|
expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
|
|
})
|
|
|
|
it.effect("loads auto-discovered local plugins as file urls", () =>
|
|
withConfigTree(
|
|
{ global: { plugin: ["my-plugin@1.0.0"] } },
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* FSUtil.use.writeWithDirs(
|
|
path.join(test.directory, ".kilo", "plugin", "my-plugin.js"), // kilocode_change
|
|
"export default {}",
|
|
)
|
|
|
|
const plugins = (yield* Config.use.get()).plugin ?? []
|
|
expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
|
|
expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p).startsWith("file://"))).toBe(true)
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
describe("KILO_DISABLE_PROJECT_CONFIG", () => {
|
|
// kilocode_change start
|
|
it.instance("skips project config files when flag is set", () =>
|
|
withProcessEnv(
|
|
"KILO_DISABLE_PROJECT_CONFIG",
|
|
"true",
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* writeConfigEffect(test.directory, { model: "project/model", username: "project-user" })
|
|
const config = yield* Config.use.get()
|
|
expect(config.model).not.toBe("project/model")
|
|
expect(config.username).not.toBe("project-user")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.instance("skips project .kilo directory when flag is set", () =>
|
|
withProcessEnv(
|
|
"KILO_DISABLE_PROJECT_CONFIG",
|
|
"true",
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* FSUtil.use.writeWithDirs(
|
|
path.join(test.directory, ".kilo", "command", "test-cmd.md"),
|
|
"# Test Command\nThis is a test command.",
|
|
)
|
|
const directories = yield* Config.use.directories()
|
|
expect(directories.some((d) => d.startsWith(test.directory))).toBe(false)
|
|
}),
|
|
),
|
|
)
|
|
// kilocode_change end
|
|
|
|
it.instance("still loads global config when flag is set", () =>
|
|
withProcessEnv(
|
|
"KILO_DISABLE_PROJECT_CONFIG",
|
|
"true",
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
expect(config).toBeDefined()
|
|
expect(config.username).toBeDefined()
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.instance(
|
|
"skips relative instructions with warning when flag is set but no config dir",
|
|
() =>
|
|
withProcessEnvs(
|
|
{ KILO_CONFIG_DIR: undefined, KILO_DISABLE_PROJECT_CONFIG: "true" },
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* FSUtil.use.writeWithDirs(path.join(test.directory, "CUSTOM.md"), "# Custom Instructions")
|
|
// The relative instruction should be skipped without error
|
|
const config = yield* Config.use.get()
|
|
expect(config).toBeDefined()
|
|
}),
|
|
),
|
|
{ config: { instructions: ["./CUSTOM.md"] } },
|
|
)
|
|
|
|
it.instance(
|
|
"KILO_CONFIG_DIR still works when flag is set",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const configDir = yield* tmpdirScoped()
|
|
// kilocode_change start
|
|
yield* writeConfigEffect(configDir, {
|
|
$schema: "https://app.kilo.ai/config.json",
|
|
model: "configdir/model",
|
|
})
|
|
// kilocode_change end
|
|
yield* withProcessEnvs(
|
|
{ KILO_DISABLE_PROJECT_CONFIG: "true", KILO_CONFIG_DIR: configDir },
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
expect(config.model).toBe("configdir/model")
|
|
}),
|
|
)
|
|
}),
|
|
{ config: { model: "project/model" } },
|
|
)
|
|
})
|
|
|
|
// Regression for #28206: malformed KILO_PERMISSION JSON used to crash
|
|
// the app on startup with an unhandled SyntaxError. Loading the config with
|
|
// an invalid JSON value in this env var should not throw.
|
|
describe("KILO_PERMISSION env var", () => {
|
|
it.instance("does not crash when KILO_PERMISSION contains invalid JSON", () =>
|
|
withProcessEnv(
|
|
"KILO_PERMISSION",
|
|
"{invalid",
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
// Regression: load() used to throw before returning anything.
|
|
expect(config).toBeDefined()
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
describe("KILO_CONFIG_CONTENT token substitution", () => {
|
|
it.instance("substitutes {env:} tokens in KILO_CONFIG_CONTENT", () =>
|
|
withProcessEnv(
|
|
"TEST_CONFIG_VAR",
|
|
"test_api_key_12345",
|
|
withProcessEnv(
|
|
"KILO_CONFIG_CONTENT",
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
username: "{env:TEST_CONFIG_VAR}",
|
|
}),
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
expect(config.username).toBe("test_api_key_12345")
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.instance("substitutes {file:} tokens in KILO_CONFIG_CONTENT", () =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
yield* FSUtil.use.writeWithDirs(path.join(test.directory, "api_key.txt"), "secret_key_from_file")
|
|
yield* withProcessEnv(
|
|
"KILO_CONFIG_CONTENT",
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
username: "{file:./api_key.txt}",
|
|
}),
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.use.get()
|
|
expect(config.username).toBe("secret_key_from_file")
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
})
|
|
|
|
// parseManagedPlist unit tests — pure function, no OS interaction
|
|
|
|
test("parseManagedPlist strips MDM metadata keys", async () => {
|
|
const config = ConfigParse.schema(
|
|
ConfigV1.Info,
|
|
ConfigParse.jsonc(
|
|
await ConfigManaged.parseManagedPlist(
|
|
JSON.stringify({
|
|
PayloadDisplayName: "OpenCode Managed",
|
|
PayloadIdentifier: "ai.opencode.managed.test",
|
|
PayloadType: "ai.opencode.managed",
|
|
PayloadUUID: "AAAA-BBBB-CCCC",
|
|
PayloadVersion: 1,
|
|
_manualProfile: true,
|
|
share: "disabled",
|
|
model: "mdm/model",
|
|
}),
|
|
),
|
|
"test:mobileconfig",
|
|
),
|
|
"test:mobileconfig",
|
|
)
|
|
expect(config.share).toBe("disabled")
|
|
expect(config.model).toBe("mdm/model")
|
|
// MDM keys must not leak into the parsed config
|
|
expect((config as any).PayloadUUID).toBeUndefined()
|
|
expect((config as any).PayloadType).toBeUndefined()
|
|
expect((config as any)._manualProfile).toBeUndefined()
|
|
})
|
|
|
|
test("parseManagedPlist parses server settings", async () => {
|
|
const config = ConfigParse.schema(
|
|
ConfigV1.Info,
|
|
ConfigParse.jsonc(
|
|
await ConfigManaged.parseManagedPlist(
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
server: { hostname: "127.0.0.1", mdns: false },
|
|
autoupdate: true,
|
|
}),
|
|
),
|
|
"test:mobileconfig",
|
|
),
|
|
"test:mobileconfig",
|
|
)
|
|
expect(config.server?.hostname).toBe("127.0.0.1")
|
|
expect(config.server?.mdns).toBe(false)
|
|
expect(config.autoupdate).toBe(true)
|
|
})
|
|
|
|
test("parseManagedPlist parses permission rules", async () => {
|
|
const config = ConfigParse.schema(
|
|
ConfigV1.Info,
|
|
ConfigParse.jsonc(
|
|
await ConfigManaged.parseManagedPlist(
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
permission: {
|
|
"*": "ask",
|
|
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
|
|
grep: "allow",
|
|
glob: "allow",
|
|
webfetch: "ask",
|
|
"~/.ssh/*": "deny",
|
|
},
|
|
}),
|
|
),
|
|
"test:mobileconfig",
|
|
),
|
|
"test:mobileconfig",
|
|
)
|
|
expect(config.permission?.["*"]).toBe("ask")
|
|
expect(config.permission?.grep).toBe("allow")
|
|
expect(config.permission?.webfetch).toBe("ask")
|
|
expect(config.permission?.["~/.ssh/*"]).toBe("deny")
|
|
const bash = config.permission?.bash as Record<string, string>
|
|
expect(bash?.["rm -rf *"]).toBe("deny")
|
|
expect(bash?.["curl *"]).toBe("deny")
|
|
})
|
|
|
|
test("parseManagedPlist parses enabled_providers", async () => {
|
|
const config = ConfigParse.schema(
|
|
ConfigV1.Info,
|
|
ConfigParse.jsonc(
|
|
await ConfigManaged.parseManagedPlist(
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
enabled_providers: ["anthropic", "google"],
|
|
}),
|
|
),
|
|
"test:mobileconfig",
|
|
),
|
|
"test:mobileconfig",
|
|
)
|
|
expect(config.enabled_providers).toEqual(["anthropic", "google"])
|
|
})
|
|
|
|
test("parseManagedPlist handles empty config", async () => {
|
|
const config = ConfigParse.schema(
|
|
ConfigV1.Info,
|
|
ConfigParse.jsonc(
|
|
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
|
|
"test:mobileconfig",
|
|
),
|
|
"test:mobileconfig",
|
|
)
|
|
expect(config.$schema).toBe("https://opencode.ai/config.json")
|
|
})
|