config.test.ts 69 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452
  1. import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test"
  2. import { Effect, Layer, Option } from "effect"
  3. import { NodeFileSystem, NodePath } from "@effect/platform-node"
  4. import { Config } from "../../src/config/config"
  5. import { Instance } from "../../src/project/instance"
  6. import { Auth } from "../../src/auth"
  7. import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
  8. import { AppFileSystem } from "../../src/filesystem"
  9. import { provideTmpdirInstance } from "../fixture/fixture"
  10. import { tmpdir } from "../fixture/fixture"
  11. import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
  12. /** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
  13. const infra = CrossSpawnSpawner.defaultLayer.pipe(
  14. Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
  15. )
  16. import path from "path"
  17. import fs from "fs/promises"
  18. import { pathToFileURL } from "url"
  19. import { Global } from "../../src/global"
  20. import { ProjectID } from "../../src/project/schema"
  21. import { Filesystem } from "../../src/util/filesystem"
  22. import * as Network from "../../src/util/network"
  23. import { Npm } from "../../src/npm"
  24. const emptyAccount = Layer.mock(Account.Service)({
  25. active: () => Effect.succeed(Option.none()),
  26. activeOrg: () => Effect.succeed(Option.none()),
  27. })
  28. const emptyAuth = Layer.mock(Auth.Service)({
  29. all: () => Effect.succeed({}),
  30. })
  31. // Get managed config directory from environment (set in preload.ts)
  32. const managedConfigDir = process.env.KILO_TEST_MANAGED_CONFIG_DIR!
  33. beforeEach(async () => {
  34. await Config.invalidate(true)
  35. })
  36. afterEach(async () => {
  37. await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
  38. await Config.invalidate(true)
  39. })
  40. async function writeManagedSettings(settings: object, filename = "kilo.json") {
  41. await fs.mkdir(managedConfigDir, { recursive: true })
  42. await Filesystem.write(path.join(managedConfigDir, filename), JSON.stringify(settings))
  43. }
  44. async function writeConfig(dir: string, config: object, name = "kilo.json") {
  45. await Filesystem.write(path.join(dir, name), JSON.stringify(config))
  46. }
  47. async function check(map: (dir: string) => string) {
  48. if (process.platform !== "win32") return
  49. await using globalTmp = await tmpdir()
  50. await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
  51. const prev = Global.Path.config
  52. ;(Global.Path as { config: string }).config = globalTmp.path
  53. await Config.invalidate()
  54. try {
  55. await writeConfig(globalTmp.path, {
  56. $schema: "https://opencode.ai/config.json",
  57. snapshot: false,
  58. })
  59. await Instance.provide({
  60. directory: map(tmp.path),
  61. fn: async () => {
  62. const cfg = await Config.get()
  63. expect(cfg.snapshot).toBe(true)
  64. expect(Instance.directory).toBe(Filesystem.resolve(tmp.path))
  65. expect(Instance.project.id).not.toBe(ProjectID.global)
  66. },
  67. })
  68. } finally {
  69. await Instance.disposeAll()
  70. ;(Global.Path as { config: string }).config = prev
  71. await Config.invalidate()
  72. }
  73. }
  74. test("loads config with defaults when no files exist", async () => {
  75. await using tmp = await tmpdir()
  76. await Instance.provide({
  77. directory: tmp.path,
  78. fn: async () => {
  79. const config = await Config.get()
  80. expect(config.username).toBeDefined()
  81. },
  82. })
  83. })
  84. test("loads JSON config file", async () => {
  85. await using tmp = await tmpdir({
  86. init: async (dir) => {
  87. await writeConfig(dir, {
  88. $schema: "https://app.kilo.ai/config.json",
  89. model: "test/model",
  90. username: "testuser",
  91. })
  92. },
  93. })
  94. await Instance.provide({
  95. directory: tmp.path,
  96. fn: async () => {
  97. const config = await Config.get()
  98. expect(config.model).toBe("test/model")
  99. expect(config.username).toBe("testuser")
  100. },
  101. })
  102. })
  103. test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
  104. // Git Bash and MSYS2 both use /<drive>/... paths on Windows.
  105. await check((dir) => {
  106. const drive = dir[0].toLowerCase()
  107. const rest = dir.slice(2).replaceAll("\\", "/")
  108. return `/${drive}${rest}`
  109. })
  110. })
  111. test("loads project config from Cygwin paths on Windows", async () => {
  112. await check((dir) => {
  113. const drive = dir[0].toLowerCase()
  114. const rest = dir.slice(2).replaceAll("\\", "/")
  115. return `/cygdrive/${drive}${rest}`
  116. })
  117. })
  118. test("ignores legacy tui keys in opencode config", async () => {
  119. await using tmp = await tmpdir({
  120. init: async (dir) => {
  121. await writeConfig(dir, {
  122. $schema: "https://opencode.ai/config.json",
  123. model: "test/model",
  124. theme: "legacy",
  125. tui: { scroll_speed: 4 },
  126. })
  127. },
  128. })
  129. await Instance.provide({
  130. directory: tmp.path,
  131. fn: async () => {
  132. const config = await Config.get()
  133. expect(config.model).toBe("test/model")
  134. expect((config as Record<string, unknown>).theme).toBeUndefined()
  135. expect((config as Record<string, unknown>).tui).toBeUndefined()
  136. },
  137. })
  138. })
  139. test("loads JSONC config file", async () => {
  140. await using tmp = await tmpdir({
  141. init: async (dir) => {
  142. await Filesystem.write(
  143. path.join(dir, "kilo.jsonc"),
  144. `{
  145. // This is a comment
  146. "$schema": "https://app.kilo.ai/config.json",
  147. "model": "test/model",
  148. "username": "testuser"
  149. }`,
  150. )
  151. },
  152. })
  153. await Instance.provide({
  154. directory: tmp.path,
  155. fn: async () => {
  156. const config = await Config.get()
  157. expect(config.model).toBe("test/model")
  158. expect(config.username).toBe("testuser")
  159. },
  160. })
  161. })
  162. test("jsonc overrides json in the same directory", async () => {
  163. await using tmp = await tmpdir({
  164. init: async (dir) => {
  165. await writeConfig(
  166. dir,
  167. {
  168. $schema: "https://app.kilo.ai/config.json",
  169. model: "base",
  170. username: "base",
  171. },
  172. "kilo.jsonc",
  173. )
  174. await writeConfig(dir, {
  175. $schema: "https://app.kilo.ai/config.json",
  176. model: "override",
  177. })
  178. },
  179. })
  180. await Instance.provide({
  181. directory: tmp.path,
  182. fn: async () => {
  183. const config = await Config.get()
  184. expect(config.model).toBe("base")
  185. expect(config.username).toBe("base")
  186. },
  187. })
  188. })
  189. test("prefers .kilo directory config over legacy .kilocode", async () => {
  190. await using tmp = await tmpdir({
  191. init: async (dir) => {
  192. await Filesystem.write(
  193. path.join(dir, ".kilocode", "kilo.json"),
  194. JSON.stringify({
  195. $schema: "https://app.kilo.ai/config.json",
  196. model: "legacy/model",
  197. }),
  198. )
  199. await Filesystem.write(
  200. path.join(dir, ".kilo", "kilo.json"),
  201. JSON.stringify({
  202. $schema: "https://app.kilo.ai/config.json",
  203. model: "new/model",
  204. }),
  205. )
  206. },
  207. })
  208. await Instance.provide({
  209. directory: tmp.path,
  210. fn: async () => {
  211. const config = await Config.get()
  212. expect(config.model).toBe("new/model")
  213. },
  214. })
  215. })
  216. test("handles environment variable substitution", async () => {
  217. const originalEnv = process.env["TEST_VAR"]
  218. process.env["TEST_VAR"] = "test-user"
  219. try {
  220. await using tmp = await tmpdir({
  221. init: async (dir) => {
  222. await writeConfig(dir, {
  223. $schema: "https://app.kilo.ai/config.json",
  224. username: "{env:TEST_VAR}",
  225. })
  226. },
  227. })
  228. await Instance.provide({
  229. directory: tmp.path,
  230. fn: async () => {
  231. const config = await Config.get()
  232. expect(config.username).toBe("test-user")
  233. },
  234. })
  235. } finally {
  236. if (originalEnv !== undefined) {
  237. process.env["TEST_VAR"] = originalEnv
  238. } else {
  239. delete process.env["TEST_VAR"]
  240. }
  241. }
  242. })
  243. test("preserves env variables when adding $schema to config", async () => {
  244. const originalEnv = process.env["PRESERVE_VAR"]
  245. process.env["PRESERVE_VAR"] = "secret_value"
  246. try {
  247. await using tmp = await tmpdir({
  248. init: async (dir) => {
  249. // Config without $schema - should trigger auto-add
  250. await Filesystem.write(
  251. path.join(dir, "kilo.json"),
  252. JSON.stringify({
  253. username: "{env:PRESERVE_VAR}",
  254. }),
  255. )
  256. },
  257. })
  258. await Instance.provide({
  259. directory: tmp.path,
  260. fn: async () => {
  261. const config = await Config.get()
  262. expect(config.username).toBe("secret_value")
  263. // Read the file to verify the env variable was preserved
  264. const content = await Filesystem.readText(path.join(tmp.path, "kilo.json"))
  265. expect(content).toContain("{env:PRESERVE_VAR}")
  266. expect(content).not.toContain("secret_value")
  267. expect(content).toContain("$schema")
  268. },
  269. })
  270. } finally {
  271. if (originalEnv !== undefined) {
  272. process.env["PRESERVE_VAR"] = originalEnv
  273. } else {
  274. delete process.env["PRESERVE_VAR"]
  275. }
  276. }
  277. })
  278. test("resolves env templates in account config with account token", async () => {
  279. const originalControlToken = process.env["KILO_CONSOLE_TOKEN"]
  280. const fakeAccount = Layer.mock(Account.Service)({
  281. active: () =>
  282. Effect.succeed(
  283. Option.some({
  284. id: AccountID.make("account-1"),
  285. email: "[email protected]",
  286. url: "https://control.example.com",
  287. active_org_id: OrgID.make("org-1"),
  288. }),
  289. ),
  290. activeOrg: () =>
  291. Effect.succeed(
  292. Option.some({
  293. account: {
  294. id: AccountID.make("account-1"),
  295. email: "[email protected]",
  296. url: "https://control.example.com",
  297. active_org_id: OrgID.make("org-1"),
  298. },
  299. org: {
  300. id: OrgID.make("org-1"),
  301. name: "Example Org",
  302. },
  303. }),
  304. ),
  305. config: () =>
  306. Effect.succeed(
  307. Option.some({
  308. provider: { opencode: { options: { apiKey: "{env:KILO_CONSOLE_TOKEN}" } } },
  309. }),
  310. ),
  311. token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
  312. })
  313. const layer = Config.layer.pipe(
  314. Layer.provide(AppFileSystem.defaultLayer),
  315. Layer.provide(emptyAuth),
  316. Layer.provide(fakeAccount),
  317. Layer.provideMerge(infra),
  318. )
  319. try {
  320. await provideTmpdirInstance(() =>
  321. Config.Service.use((svc) =>
  322. Effect.gen(function* () {
  323. const config = yield* svc.get()
  324. expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
  325. }),
  326. ),
  327. ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
  328. } finally {
  329. if (originalControlToken !== undefined) {
  330. process.env["KILO_CONSOLE_TOKEN"] = originalControlToken
  331. } else {
  332. delete process.env["KILO_CONSOLE_TOKEN"]
  333. }
  334. }
  335. })
  336. test("handles file inclusion substitution", async () => {
  337. await using tmp = await tmpdir({
  338. init: async (dir) => {
  339. await Filesystem.write(path.join(dir, "included.txt"), "test-user")
  340. await writeConfig(dir, {
  341. $schema: "https://app.kilo.ai/config.json",
  342. username: "{file:included.txt}",
  343. })
  344. },
  345. })
  346. await Instance.provide({
  347. directory: tmp.path,
  348. fn: async () => {
  349. const config = await Config.get()
  350. expect(config.username).toBe("test-user")
  351. },
  352. })
  353. })
  354. test("handles file inclusion with replacement tokens", async () => {
  355. await using tmp = await tmpdir({
  356. init: async (dir) => {
  357. await Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
  358. await writeConfig(dir, {
  359. $schema: "https://app.kilo.ai/config.json",
  360. username: "{file:included.md}",
  361. })
  362. },
  363. })
  364. await Instance.provide({
  365. directory: tmp.path,
  366. fn: async () => {
  367. const config = await Config.get()
  368. expect(config.username).toBe("const out = await Bun.$`echo hi`")
  369. },
  370. })
  371. })
  372. test("validates config schema and reports warning on invalid fields", async () => {
  373. await using tmp = await tmpdir({
  374. init: async (dir) => {
  375. await writeConfig(dir, {
  376. $schema: "https://app.kilo.ai/config.json",
  377. invalid_field: "should cause error",
  378. })
  379. },
  380. })
  381. await Instance.provide({
  382. directory: tmp.path,
  383. fn: async () => {
  384. // Invalid fields are caught as warnings, config loads with defaults
  385. const config = await Config.get()
  386. expect(config).toBeDefined()
  387. const warns = await Config.warnings()
  388. expect(warns.length).toBeGreaterThan(0)
  389. },
  390. })
  391. })
  392. test("reports warning for invalid JSON", async () => {
  393. await using tmp = await tmpdir({
  394. init: async (dir) => {
  395. await Filesystem.write(path.join(dir, "kilo.json"), "{ invalid json }")
  396. },
  397. })
  398. await Instance.provide({
  399. directory: tmp.path,
  400. fn: async () => {
  401. // Invalid JSON is caught as a warning, config loads with defaults
  402. const config = await Config.get()
  403. expect(config).toBeDefined()
  404. const warns = await Config.warnings()
  405. expect(warns.length).toBeGreaterThan(0)
  406. },
  407. })
  408. })
  409. test("handles agent configuration", async () => {
  410. await using tmp = await tmpdir({
  411. init: async (dir) => {
  412. await writeConfig(dir, {
  413. $schema: "https://app.kilo.ai/config.json",
  414. agent: {
  415. test_agent: {
  416. model: "test/model",
  417. temperature: 0.7,
  418. description: "test agent",
  419. },
  420. },
  421. })
  422. },
  423. })
  424. await Instance.provide({
  425. directory: tmp.path,
  426. fn: async () => {
  427. const config = await Config.get()
  428. expect(config.agent?.["test_agent"]).toEqual(
  429. expect.objectContaining({
  430. model: "test/model",
  431. temperature: 0.7,
  432. description: "test agent",
  433. }),
  434. )
  435. },
  436. })
  437. })
  438. test("treats agent variant as model-scoped setting (not provider option)", async () => {
  439. await using tmp = await tmpdir({
  440. init: async (dir) => {
  441. await writeConfig(dir, {
  442. $schema: "https://app.kilo.ai/config.json",
  443. agent: {
  444. test_agent: {
  445. model: "openai/gpt-5.2",
  446. variant: "xhigh",
  447. max_tokens: 123,
  448. },
  449. },
  450. })
  451. },
  452. })
  453. await Instance.provide({
  454. directory: tmp.path,
  455. fn: async () => {
  456. const config = await Config.get()
  457. const agent = config.agent?.["test_agent"]
  458. expect(agent?.variant).toBe("xhigh")
  459. expect(agent?.options).toMatchObject({
  460. max_tokens: 123,
  461. })
  462. expect(agent?.options).not.toHaveProperty("variant")
  463. },
  464. })
  465. })
  466. test("handles command configuration", async () => {
  467. await using tmp = await tmpdir({
  468. init: async (dir) => {
  469. await writeConfig(dir, {
  470. $schema: "https://app.kilo.ai/config.json",
  471. command: {
  472. test_command: {
  473. template: "test template",
  474. description: "test command",
  475. agent: "test_agent",
  476. },
  477. },
  478. })
  479. },
  480. })
  481. await Instance.provide({
  482. directory: tmp.path,
  483. fn: async () => {
  484. const config = await Config.get()
  485. expect(config.command?.["test_command"]).toEqual({
  486. template: "test template",
  487. description: "test command",
  488. agent: "test_agent",
  489. })
  490. },
  491. })
  492. })
  493. test("migrates autoshare to share field", async () => {
  494. await using tmp = await tmpdir({
  495. init: async (dir) => {
  496. await Filesystem.write(
  497. path.join(dir, "kilo.json"),
  498. JSON.stringify({
  499. $schema: "https://app.kilo.ai/config.json",
  500. autoshare: true,
  501. }),
  502. )
  503. },
  504. })
  505. await Instance.provide({
  506. directory: tmp.path,
  507. fn: async () => {
  508. const config = await Config.get()
  509. expect(config.share).toBe("auto")
  510. expect(config.autoshare).toBe(true)
  511. },
  512. })
  513. })
  514. test("migrates mode field to agent field", async () => {
  515. await using tmp = await tmpdir({
  516. init: async (dir) => {
  517. await Filesystem.write(
  518. path.join(dir, "kilo.json"),
  519. JSON.stringify({
  520. $schema: "https://app.kilo.ai/config.json",
  521. mode: {
  522. test_mode: {
  523. model: "test/model",
  524. temperature: 0.5,
  525. },
  526. },
  527. }),
  528. )
  529. },
  530. })
  531. await Instance.provide({
  532. directory: tmp.path,
  533. fn: async () => {
  534. const config = await Config.get()
  535. expect(config.agent?.["test_mode"]).toEqual({
  536. model: "test/model",
  537. temperature: 0.5,
  538. mode: "primary",
  539. options: {},
  540. permission: {},
  541. })
  542. },
  543. })
  544. })
  545. test("loads config from .kilo directory", async () => {
  546. await using tmp = await tmpdir({
  547. init: async (dir) => {
  548. const opencodeDir = path.join(dir, ".kilo")
  549. await fs.mkdir(opencodeDir, { recursive: true })
  550. const agentDir = path.join(opencodeDir, "agent")
  551. await fs.mkdir(agentDir, { recursive: true })
  552. await Filesystem.write(
  553. path.join(agentDir, "test.md"),
  554. `---
  555. model: test/model
  556. ---
  557. Test agent prompt`,
  558. )
  559. },
  560. })
  561. await Instance.provide({
  562. directory: tmp.path,
  563. fn: async () => {
  564. const config = await Config.get()
  565. expect(config.agent?.["test"]).toEqual(
  566. expect.objectContaining({
  567. name: "test",
  568. model: "test/model",
  569. prompt: "Test agent prompt",
  570. }),
  571. )
  572. },
  573. })
  574. })
  575. test("loads agents from .kilo/agents (plural)", async () => {
  576. await using tmp = await tmpdir({
  577. init: async (dir) => {
  578. const opencodeDir = path.join(dir, ".kilo")
  579. await fs.mkdir(opencodeDir, { recursive: true })
  580. const agentsDir = path.join(opencodeDir, "agents")
  581. await fs.mkdir(path.join(agentsDir, "nested"), { recursive: true })
  582. await Filesystem.write(
  583. path.join(agentsDir, "helper.md"),
  584. `---
  585. model: test/model
  586. mode: subagent
  587. ---
  588. Helper agent prompt`,
  589. )
  590. await Filesystem.write(
  591. path.join(agentsDir, "nested", "child.md"),
  592. `---
  593. model: test/model
  594. mode: subagent
  595. ---
  596. Nested agent prompt`,
  597. )
  598. },
  599. })
  600. await Instance.provide({
  601. directory: tmp.path,
  602. fn: async () => {
  603. const config = await Config.get()
  604. expect(config.agent?.["helper"]).toMatchObject({
  605. name: "helper",
  606. model: "test/model",
  607. mode: "subagent",
  608. prompt: "Helper agent prompt",
  609. })
  610. expect(config.agent?.["nested/child"]).toMatchObject({
  611. name: "nested/child",
  612. model: "test/model",
  613. mode: "subagent",
  614. prompt: "Nested agent prompt",
  615. })
  616. },
  617. })
  618. })
  619. test("loads commands from .kilo/command (singular)", async () => {
  620. await using tmp = await tmpdir({
  621. init: async (dir) => {
  622. const opencodeDir = path.join(dir, ".kilo")
  623. await fs.mkdir(opencodeDir, { recursive: true })
  624. const commandDir = path.join(opencodeDir, "command")
  625. await fs.mkdir(path.join(commandDir, "nested"), { recursive: true })
  626. await Filesystem.write(
  627. path.join(commandDir, "hello.md"),
  628. `---
  629. description: Test command
  630. ---
  631. Hello from singular command`,
  632. )
  633. await Filesystem.write(
  634. path.join(commandDir, "nested", "child.md"),
  635. `---
  636. description: Nested command
  637. ---
  638. Nested command template`,
  639. )
  640. },
  641. })
  642. await Instance.provide({
  643. directory: tmp.path,
  644. fn: async () => {
  645. const config = await Config.get()
  646. expect(config.command?.["hello"]).toEqual({
  647. description: "Test command",
  648. template: "Hello from singular command",
  649. })
  650. expect(config.command?.["nested/child"]).toEqual({
  651. description: "Nested command",
  652. template: "Nested command template",
  653. })
  654. },
  655. })
  656. })
  657. test("loads commands from .kilo/commands (plural)", async () => {
  658. await using tmp = await tmpdir({
  659. init: async (dir) => {
  660. const opencodeDir = path.join(dir, ".kilo")
  661. await fs.mkdir(opencodeDir, { recursive: true })
  662. const commandsDir = path.join(opencodeDir, "commands")
  663. await fs.mkdir(path.join(commandsDir, "nested"), { recursive: true })
  664. await Filesystem.write(
  665. path.join(commandsDir, "hello.md"),
  666. `---
  667. description: Test command
  668. ---
  669. Hello from plural commands`,
  670. )
  671. await Filesystem.write(
  672. path.join(commandsDir, "nested", "child.md"),
  673. `---
  674. description: Nested command
  675. ---
  676. Nested command template`,
  677. )
  678. },
  679. })
  680. await Instance.provide({
  681. directory: tmp.path,
  682. fn: async () => {
  683. const config = await Config.get()
  684. expect(config.command?.["hello"]).toEqual({
  685. description: "Test command",
  686. template: "Hello from plural commands",
  687. })
  688. expect(config.command?.["nested/child"]).toEqual({
  689. description: "Nested command",
  690. template: "Nested command template",
  691. })
  692. },
  693. })
  694. })
  695. test("prefers .kilo commands over legacy .kilocode commands", async () => {
  696. await using tmp = await tmpdir({
  697. init: async (dir) => {
  698. await Filesystem.write(
  699. path.join(dir, ".kilocode", "command", "hello.md"),
  700. `---
  701. description: Legacy command
  702. ---
  703. Hello from legacy command`,
  704. )
  705. await Filesystem.write(
  706. path.join(dir, ".kilo", "command", "hello.md"),
  707. `---
  708. description: New command
  709. ---
  710. Hello from new command`,
  711. )
  712. },
  713. })
  714. await Instance.provide({
  715. directory: tmp.path,
  716. fn: async () => {
  717. const config = await Config.get()
  718. expect(config.command?.["hello"]).toEqual({
  719. description: "New command",
  720. template: "Hello from new command",
  721. })
  722. },
  723. })
  724. })
  725. test("updates config and writes to file", async () => {
  726. await using tmp = await tmpdir()
  727. await Instance.provide({
  728. directory: tmp.path,
  729. fn: async () => {
  730. const newConfig = { model: "updated/model" }
  731. await Config.update(newConfig as any)
  732. const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json"))
  733. expect(writtenConfig.model).toBe("updated/model")
  734. },
  735. })
  736. })
  737. test("gets config directories", async () => {
  738. await using tmp = await tmpdir()
  739. await Instance.provide({
  740. directory: tmp.path,
  741. fn: async () => {
  742. const dirs = await Config.directories()
  743. expect(dirs.length).toBeGreaterThanOrEqual(1)
  744. },
  745. })
  746. })
  747. test("does not try to install dependencies in read-only KILO_CONFIG_DIR", async () => {
  748. if (process.platform === "win32") return
  749. await using tmp = await tmpdir<string>({
  750. init: async (dir) => {
  751. const ro = path.join(dir, "readonly")
  752. await fs.mkdir(ro, { recursive: true })
  753. await fs.chmod(ro, 0o555)
  754. return ro
  755. },
  756. dispose: async (dir) => {
  757. const ro = path.join(dir, "readonly")
  758. await fs.chmod(ro, 0o755).catch(() => {})
  759. return ro
  760. },
  761. })
  762. const prev = process.env.KILO_CONFIG_DIR
  763. process.env.KILO_CONFIG_DIR = tmp.extra
  764. try {
  765. await Instance.provide({
  766. directory: tmp.path,
  767. fn: async () => {
  768. await Config.get()
  769. },
  770. })
  771. } finally {
  772. if (prev === undefined) delete process.env.KILO_CONFIG_DIR
  773. else process.env.KILO_CONFIG_DIR = prev
  774. }
  775. })
  776. test("installs dependencies in writable KILO_CONFIG_DIR", async () => {
  777. await using tmp = await tmpdir<string>({
  778. init: async (dir) => {
  779. const cfg = path.join(dir, "configdir")
  780. await fs.mkdir(cfg, { recursive: true })
  781. return cfg
  782. },
  783. })
  784. const prev = process.env.KILO_CONFIG_DIR
  785. process.env.KILO_CONFIG_DIR = tmp.extra
  786. const online = spyOn(Network, "online").mockReturnValue(false)
  787. const install = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
  788. const mod = path.join(dir, "node_modules", "@kilocode", "plugin")
  789. await fs.mkdir(mod, { recursive: true })
  790. await Filesystem.write(
  791. path.join(mod, "package.json"),
  792. JSON.stringify({ name: "@kilocode/plugin", version: "1.0.0" }),
  793. )
  794. })
  795. try {
  796. await Instance.provide({
  797. directory: tmp.path,
  798. fn: async () => {
  799. await Config.get()
  800. await Config.waitForDependencies()
  801. },
  802. })
  803. expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true)
  804. expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
  805. expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
  806. } finally {
  807. online.mockRestore()
  808. install.mockRestore()
  809. if (prev === undefined) delete process.env.KILO_CONFIG_DIR
  810. else process.env.KILO_CONFIG_DIR = prev
  811. }
  812. })
  813. test("dedupes concurrent config dependency installs for the same dir", async () => {
  814. await using tmp = await tmpdir()
  815. const dir = path.join(tmp.path, "a")
  816. await fs.mkdir(dir, { recursive: true })
  817. const ticks: number[] = []
  818. let calls = 0
  819. let start = () => {}
  820. let done = () => {}
  821. let blocked = () => {}
  822. const ready = new Promise<void>((resolve) => {
  823. start = resolve
  824. })
  825. const gate = new Promise<void>((resolve) => {
  826. done = resolve
  827. })
  828. const waiting = new Promise<void>((resolve) => {
  829. blocked = resolve
  830. })
  831. const online = spyOn(Network, "online").mockReturnValue(false)
  832. const targetDir = dir
  833. const run = spyOn(Npm, "install").mockImplementation(async (d: string) => {
  834. const hit = path.normalize(d) === path.normalize(targetDir)
  835. if (hit) {
  836. calls += 1
  837. start()
  838. await gate
  839. }
  840. const mod = path.join(d, "node_modules", "@kilocode", "plugin") // kilocode_change
  841. await fs.mkdir(mod, { recursive: true })
  842. await Filesystem.write(
  843. path.join(mod, "package.json"),
  844. JSON.stringify({ name: "@kilocode/plugin", version: "1.0.0" }),
  845. )
  846. if (hit) {
  847. start()
  848. await gate
  849. }
  850. })
  851. try {
  852. const first = Config.installDependencies(dir)
  853. await ready
  854. const second = Config.installDependencies(dir, {
  855. waitTick: (tick) => {
  856. ticks.push(tick.attempt)
  857. blocked()
  858. blocked = () => {}
  859. },
  860. })
  861. await waiting
  862. done()
  863. await Promise.all([first, second])
  864. } finally {
  865. online.mockRestore()
  866. run.mockRestore()
  867. }
  868. expect(calls).toBe(2)
  869. expect(ticks.length).toBeGreaterThan(0)
  870. expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true)
  871. })
  872. test("serializes config dependency installs across dirs", async () => {
  873. if (process.platform !== "win32") return
  874. await using tmp = await tmpdir()
  875. const a = path.join(tmp.path, "a")
  876. const b = path.join(tmp.path, "b")
  877. await fs.mkdir(a, { recursive: true })
  878. await fs.mkdir(b, { recursive: true })
  879. let calls = 0
  880. let open = 0
  881. let peak = 0
  882. let start = () => {}
  883. let done = () => {}
  884. const ready = new Promise<void>((resolve) => {
  885. start = resolve
  886. })
  887. const gate = new Promise<void>((resolve) => {
  888. done = resolve
  889. })
  890. const online = spyOn(Network, "online").mockReturnValue(false)
  891. const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
  892. const cwd = path.normalize(dir)
  893. const hit = cwd === path.normalize(a) || cwd === path.normalize(b)
  894. if (hit) {
  895. calls += 1
  896. open += 1
  897. peak = Math.max(peak, open)
  898. if (calls === 1) {
  899. start()
  900. await gate
  901. }
  902. }
  903. const mod = path.join(cwd, "node_modules", "@kilocode", "plugin") // kilocode_change
  904. await fs.mkdir(mod, { recursive: true })
  905. await Filesystem.write(
  906. path.join(mod, "package.json"),
  907. JSON.stringify({ name: "@kilocode/plugin", version: "1.0.0" }),
  908. )
  909. if (hit) {
  910. open -= 1
  911. }
  912. })
  913. try {
  914. const first = Config.installDependencies(a)
  915. await ready
  916. const second = Config.installDependencies(b)
  917. done()
  918. await Promise.all([first, second])
  919. } finally {
  920. online.mockRestore()
  921. run.mockRestore()
  922. }
  923. expect(calls).toBe(2)
  924. expect(peak).toBe(1)
  925. })
  926. test("resolves scoped npm plugins in config", async () => {
  927. await using tmp = await tmpdir({
  928. init: async (dir) => {
  929. const pluginDir = path.join(dir, "node_modules", "@scope", "plugin")
  930. await fs.mkdir(pluginDir, { recursive: true })
  931. await Filesystem.write(
  932. path.join(dir, "package.json"),
  933. JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
  934. )
  935. await Filesystem.write(
  936. path.join(pluginDir, "package.json"),
  937. JSON.stringify(
  938. {
  939. name: "@scope/plugin",
  940. version: "1.0.0",
  941. type: "module",
  942. main: "./index.js",
  943. },
  944. null,
  945. 2,
  946. ),
  947. )
  948. await Filesystem.write(path.join(pluginDir, "index.js"), "export default {}\n")
  949. await Filesystem.write(
  950. path.join(dir, "kilo.json"),
  951. JSON.stringify({ $schema: "https://app.kilo.ai/config.json", plugin: ["@scope/plugin"] }, null, 2),
  952. )
  953. },
  954. })
  955. await Instance.provide({
  956. directory: tmp.path,
  957. fn: async () => {
  958. const config = await Config.get()
  959. const pluginEntries = config.plugin ?? []
  960. expect(pluginEntries).toContain("@scope/plugin")
  961. },
  962. })
  963. })
  964. test("merges plugin arrays from global and local configs", async () => {
  965. await using tmp = await tmpdir({
  966. init: async (dir) => {
  967. // Create a nested project structure with local .kilo config
  968. const projectDir = path.join(dir, "project")
  969. const opencodeDir = path.join(projectDir, ".kilo")
  970. await fs.mkdir(opencodeDir, { recursive: true })
  971. // Global config with plugins
  972. await Filesystem.write(
  973. path.join(dir, "kilo.json"),
  974. JSON.stringify({
  975. $schema: "https://app.kilo.ai/config.json",
  976. plugin: ["global-plugin-1", "global-plugin-2"],
  977. }),
  978. )
  979. // Local .kilo config with different plugins
  980. await Filesystem.write(
  981. path.join(opencodeDir, "kilo.json"),
  982. JSON.stringify({
  983. $schema: "https://app.kilo.ai/config.json",
  984. plugin: ["local-plugin-1"],
  985. }),
  986. )
  987. },
  988. })
  989. await Instance.provide({
  990. directory: path.join(tmp.path, "project"),
  991. fn: async () => {
  992. const config = await Config.get()
  993. const plugins = config.plugin ?? []
  994. // Should contain both global and local plugins
  995. expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
  996. expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true)
  997. expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
  998. // Should have all 3 plugins (not replaced, but merged)
  999. const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin"))
  1000. expect(pluginNames.length).toBeGreaterThanOrEqual(3)
  1001. },
  1002. })
  1003. })
  1004. test("does not error when only custom agent is a subagent", async () => {
  1005. await using tmp = await tmpdir({
  1006. init: async (dir) => {
  1007. const opencodeDir = path.join(dir, ".kilo")
  1008. await fs.mkdir(opencodeDir, { recursive: true })
  1009. const agentDir = path.join(opencodeDir, "agent")
  1010. await fs.mkdir(agentDir, { recursive: true })
  1011. await Filesystem.write(
  1012. path.join(agentDir, "helper.md"),
  1013. `---
  1014. model: test/model
  1015. mode: subagent
  1016. ---
  1017. Helper subagent prompt`,
  1018. )
  1019. },
  1020. })
  1021. await Instance.provide({
  1022. directory: tmp.path,
  1023. fn: async () => {
  1024. const config = await Config.get()
  1025. expect(config.agent?.["helper"]).toMatchObject({
  1026. name: "helper",
  1027. model: "test/model",
  1028. mode: "subagent",
  1029. prompt: "Helper subagent prompt",
  1030. })
  1031. },
  1032. })
  1033. })
  1034. test("merges instructions arrays from global and local configs", async () => {
  1035. await using tmp = await tmpdir({
  1036. init: async (dir) => {
  1037. const projectDir = path.join(dir, "project")
  1038. const opencodeDir = path.join(projectDir, ".kilo")
  1039. await fs.mkdir(opencodeDir, { recursive: true })
  1040. await Filesystem.write(
  1041. path.join(dir, "kilo.json"),
  1042. JSON.stringify({
  1043. $schema: "https://app.kilo.ai/config.json",
  1044. instructions: ["global-instructions.md", "shared-rules.md"],
  1045. }),
  1046. )
  1047. await Filesystem.write(
  1048. path.join(opencodeDir, "kilo.json"),
  1049. JSON.stringify({
  1050. $schema: "https://app.kilo.ai/config.json",
  1051. instructions: ["local-instructions.md"],
  1052. }),
  1053. )
  1054. },
  1055. })
  1056. await Instance.provide({
  1057. directory: path.join(tmp.path, "project"),
  1058. fn: async () => {
  1059. const config = await Config.get()
  1060. const instructions = config.instructions ?? []
  1061. expect(instructions).toContain("global-instructions.md")
  1062. expect(instructions).toContain("shared-rules.md")
  1063. expect(instructions).toContain("local-instructions.md")
  1064. expect(instructions.length).toBe(3)
  1065. },
  1066. })
  1067. })
  1068. test("deduplicates duplicate instructions from global and local configs", async () => {
  1069. await using tmp = await tmpdir({
  1070. init: async (dir) => {
  1071. const projectDir = path.join(dir, "project")
  1072. const opencodeDir = path.join(projectDir, ".kilo")
  1073. await fs.mkdir(opencodeDir, { recursive: true })
  1074. await Filesystem.write(
  1075. path.join(dir, "kilo.json"),
  1076. JSON.stringify({
  1077. $schema: "https://app.kilo.ai/config.json",
  1078. instructions: ["duplicate.md", "global-only.md"],
  1079. }),
  1080. )
  1081. await Filesystem.write(
  1082. path.join(opencodeDir, "kilo.json"),
  1083. JSON.stringify({
  1084. $schema: "https://app.kilo.ai/config.json",
  1085. instructions: ["duplicate.md", "local-only.md"],
  1086. }),
  1087. )
  1088. },
  1089. })
  1090. await Instance.provide({
  1091. directory: path.join(tmp.path, "project"),
  1092. fn: async () => {
  1093. const config = await Config.get()
  1094. const instructions = config.instructions ?? []
  1095. expect(instructions).toContain("global-only.md")
  1096. expect(instructions).toContain("local-only.md")
  1097. expect(instructions).toContain("duplicate.md")
  1098. const duplicates = instructions.filter((i) => i === "duplicate.md")
  1099. expect(duplicates.length).toBe(1)
  1100. expect(instructions.length).toBe(3)
  1101. },
  1102. })
  1103. })
  1104. test("deduplicates duplicate plugins from global and local configs", async () => {
  1105. await using tmp = await tmpdir({
  1106. init: async (dir) => {
  1107. // Create a nested project structure with local .kilo config
  1108. const projectDir = path.join(dir, "project")
  1109. const opencodeDir = path.join(projectDir, ".kilo")
  1110. await fs.mkdir(opencodeDir, { recursive: true })
  1111. // Global config with plugins
  1112. await Filesystem.write(
  1113. path.join(dir, "kilo.json"),
  1114. JSON.stringify({
  1115. $schema: "https://app.kilo.ai/config.json",
  1116. plugin: ["duplicate-plugin", "global-plugin-1"],
  1117. }),
  1118. )
  1119. // Local .kilo config with some overlapping plugins
  1120. await Filesystem.write(
  1121. path.join(opencodeDir, "kilo.json"),
  1122. JSON.stringify({
  1123. $schema: "https://app.kilo.ai/config.json",
  1124. plugin: ["duplicate-plugin", "local-plugin-1"],
  1125. }),
  1126. )
  1127. },
  1128. })
  1129. await Instance.provide({
  1130. directory: path.join(tmp.path, "project"),
  1131. fn: async () => {
  1132. const config = await Config.get()
  1133. const plugins = config.plugin ?? []
  1134. // Should contain all unique plugins
  1135. expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
  1136. expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
  1137. expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true)
  1138. // Should deduplicate the duplicate plugin
  1139. const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin"))
  1140. expect(duplicatePlugins.length).toBe(1)
  1141. // Should have exactly 3 unique plugins
  1142. const pluginNames = plugins.filter(
  1143. (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
  1144. )
  1145. expect(pluginNames.length).toBe(3)
  1146. },
  1147. })
  1148. })
  1149. test("keeps plugin origins aligned with merged plugin list", async () => {
  1150. await using tmp = await tmpdir({
  1151. init: async (dir) => {
  1152. const project = path.join(dir, "project")
  1153. const local = path.join(project, ".opencode")
  1154. await fs.mkdir(local, { recursive: true })
  1155. await Filesystem.write(
  1156. path.join(dir, "opencode.json"),
  1157. JSON.stringify({
  1158. $schema: "https://opencode.ai/config.json",
  1159. plugin: [["[email protected]", { source: "global" }], "[email protected]"],
  1160. }),
  1161. )
  1162. await Filesystem.write(
  1163. path.join(local, "opencode.json"),
  1164. JSON.stringify({
  1165. $schema: "https://opencode.ai/config.json",
  1166. plugin: [["[email protected]", { source: "local" }], "[email protected]"],
  1167. }),
  1168. )
  1169. },
  1170. })
  1171. await Instance.provide({
  1172. directory: path.join(tmp.path, "project"),
  1173. fn: async () => {
  1174. const cfg = await Config.get()
  1175. const plugins = cfg.plugin ?? []
  1176. const origins = cfg.plugin_origins ?? []
  1177. const names = plugins.map((item) => Config.pluginSpecifier(item))
  1178. expect(names).toContain("[email protected]")
  1179. expect(names).not.toContain("[email protected]")
  1180. expect(names).toContain("[email protected]")
  1181. expect(names).toContain("[email protected]")
  1182. expect(origins.map((item) => item.spec)).toEqual(plugins)
  1183. const hit = origins.find((item) => Config.pluginSpecifier(item.spec) === "[email protected]")
  1184. expect(hit?.scope).toBe("local")
  1185. },
  1186. })
  1187. })
  1188. // Legacy tools migration tests
  1189. test("migrates legacy tools config to permissions - allow", async () => {
  1190. await using tmp = await tmpdir({
  1191. init: async (dir) => {
  1192. await Filesystem.write(
  1193. path.join(dir, "kilo.json"),
  1194. JSON.stringify({
  1195. $schema: "https://app.kilo.ai/config.json",
  1196. agent: {
  1197. test: {
  1198. tools: {
  1199. bash: true,
  1200. read: true,
  1201. },
  1202. },
  1203. },
  1204. }),
  1205. )
  1206. },
  1207. })
  1208. await Instance.provide({
  1209. directory: tmp.path,
  1210. fn: async () => {
  1211. const config = await Config.get()
  1212. expect(config.agent?.["test"]?.permission).toEqual({
  1213. bash: "allow",
  1214. read: "allow",
  1215. })
  1216. },
  1217. })
  1218. })
  1219. test("migrates legacy tools config to permissions - deny", async () => {
  1220. await using tmp = await tmpdir({
  1221. init: async (dir) => {
  1222. await Filesystem.write(
  1223. path.join(dir, "kilo.json"),
  1224. JSON.stringify({
  1225. $schema: "https://app.kilo.ai/config.json",
  1226. agent: {
  1227. test: {
  1228. tools: {
  1229. bash: false,
  1230. webfetch: false,
  1231. },
  1232. },
  1233. },
  1234. }),
  1235. )
  1236. },
  1237. })
  1238. await Instance.provide({
  1239. directory: tmp.path,
  1240. fn: async () => {
  1241. const config = await Config.get()
  1242. expect(config.agent?.["test"]?.permission).toEqual({
  1243. bash: "deny",
  1244. webfetch: "deny",
  1245. })
  1246. },
  1247. })
  1248. })
  1249. test("migrates legacy write tool to edit permission", async () => {
  1250. await using tmp = await tmpdir({
  1251. init: async (dir) => {
  1252. await Filesystem.write(
  1253. path.join(dir, "kilo.json"),
  1254. JSON.stringify({
  1255. $schema: "https://app.kilo.ai/config.json",
  1256. agent: {
  1257. test: {
  1258. tools: {
  1259. write: true,
  1260. },
  1261. },
  1262. },
  1263. }),
  1264. )
  1265. },
  1266. })
  1267. await Instance.provide({
  1268. directory: tmp.path,
  1269. fn: async () => {
  1270. const config = await Config.get()
  1271. expect(config.agent?.["test"]?.permission).toEqual({
  1272. edit: "allow",
  1273. })
  1274. },
  1275. })
  1276. })
  1277. // Managed settings tests
  1278. // Note: preload.ts sets KILO_TEST_MANAGED_CONFIG which Global.Path.managedConfig uses
  1279. test("managed settings override user settings", async () => {
  1280. await using tmp = await tmpdir({
  1281. init: async (dir) => {
  1282. await writeConfig(dir, {
  1283. $schema: "https://app.kilo.ai/config.json",
  1284. model: "user/model",
  1285. share: "auto",
  1286. username: "testuser",
  1287. })
  1288. },
  1289. })
  1290. await writeManagedSettings({
  1291. $schema: "https://app.kilo.ai/config.json",
  1292. model: "managed/model",
  1293. share: "disabled",
  1294. })
  1295. await Instance.provide({
  1296. directory: tmp.path,
  1297. fn: async () => {
  1298. const config = await Config.get()
  1299. expect(config.model).toBe("managed/model")
  1300. expect(config.share).toBe("disabled")
  1301. expect(config.username).toBe("testuser")
  1302. },
  1303. })
  1304. })
  1305. test("managed settings override project settings", async () => {
  1306. await using tmp = await tmpdir({
  1307. init: async (dir) => {
  1308. await writeConfig(dir, {
  1309. $schema: "https://app.kilo.ai/config.json",
  1310. autoupdate: true,
  1311. disabled_providers: [],
  1312. })
  1313. },
  1314. })
  1315. await writeManagedSettings({
  1316. $schema: "https://app.kilo.ai/config.json",
  1317. autoupdate: false,
  1318. disabled_providers: ["openai"],
  1319. })
  1320. await Instance.provide({
  1321. directory: tmp.path,
  1322. fn: async () => {
  1323. const config = await Config.get()
  1324. expect(config.autoupdate).toBe(false)
  1325. expect(config.disabled_providers).toEqual(["openai"])
  1326. },
  1327. })
  1328. })
  1329. test("missing managed settings file is not an error", async () => {
  1330. await using tmp = await tmpdir({
  1331. init: async (dir) => {
  1332. await writeConfig(dir, {
  1333. $schema: "https://app.kilo.ai/config.json",
  1334. model: "user/model",
  1335. })
  1336. },
  1337. })
  1338. await Instance.provide({
  1339. directory: tmp.path,
  1340. fn: async () => {
  1341. const config = await Config.get()
  1342. expect(config.model).toBe("user/model")
  1343. },
  1344. })
  1345. })
  1346. test("migrates legacy edit tool to edit permission", async () => {
  1347. await using tmp = await tmpdir({
  1348. init: async (dir) => {
  1349. await Filesystem.write(
  1350. path.join(dir, "kilo.json"),
  1351. JSON.stringify({
  1352. $schema: "https://app.kilo.ai/config.json",
  1353. agent: {
  1354. test: {
  1355. tools: {
  1356. edit: false,
  1357. },
  1358. },
  1359. },
  1360. }),
  1361. )
  1362. },
  1363. })
  1364. await Instance.provide({
  1365. directory: tmp.path,
  1366. fn: async () => {
  1367. const config = await Config.get()
  1368. expect(config.agent?.["test"]?.permission).toEqual({
  1369. edit: "deny",
  1370. })
  1371. },
  1372. })
  1373. })
  1374. test("migrates legacy patch tool to edit permission", async () => {
  1375. await using tmp = await tmpdir({
  1376. init: async (dir) => {
  1377. await Filesystem.write(
  1378. path.join(dir, "kilo.json"),
  1379. JSON.stringify({
  1380. $schema: "https://app.kilo.ai/config.json",
  1381. agent: {
  1382. test: {
  1383. tools: {
  1384. patch: true,
  1385. },
  1386. },
  1387. },
  1388. }),
  1389. )
  1390. },
  1391. })
  1392. await Instance.provide({
  1393. directory: tmp.path,
  1394. fn: async () => {
  1395. const config = await Config.get()
  1396. expect(config.agent?.["test"]?.permission).toEqual({
  1397. edit: "allow",
  1398. })
  1399. },
  1400. })
  1401. })
  1402. test("migrates legacy multiedit tool to edit permission", async () => {
  1403. await using tmp = await tmpdir({
  1404. init: async (dir) => {
  1405. await Filesystem.write(
  1406. path.join(dir, "kilo.json"),
  1407. JSON.stringify({
  1408. $schema: "https://app.kilo.ai/config.json",
  1409. agent: {
  1410. test: {
  1411. tools: {
  1412. multiedit: false,
  1413. },
  1414. },
  1415. },
  1416. }),
  1417. )
  1418. },
  1419. })
  1420. await Instance.provide({
  1421. directory: tmp.path,
  1422. fn: async () => {
  1423. const config = await Config.get()
  1424. expect(config.agent?.["test"]?.permission).toEqual({
  1425. edit: "deny",
  1426. })
  1427. },
  1428. })
  1429. })
  1430. test("migrates mixed legacy tools config", async () => {
  1431. await using tmp = await tmpdir({
  1432. init: async (dir) => {
  1433. await Filesystem.write(
  1434. path.join(dir, "kilo.json"),
  1435. JSON.stringify({
  1436. $schema: "https://app.kilo.ai/config.json",
  1437. agent: {
  1438. test: {
  1439. tools: {
  1440. bash: true,
  1441. write: true,
  1442. read: false,
  1443. webfetch: true,
  1444. },
  1445. },
  1446. },
  1447. }),
  1448. )
  1449. },
  1450. })
  1451. await Instance.provide({
  1452. directory: tmp.path,
  1453. fn: async () => {
  1454. const config = await Config.get()
  1455. expect(config.agent?.["test"]?.permission).toEqual({
  1456. bash: "allow",
  1457. edit: "allow",
  1458. read: "deny",
  1459. webfetch: "allow",
  1460. })
  1461. },
  1462. })
  1463. })
  1464. test("merges legacy tools with existing permission config", async () => {
  1465. await using tmp = await tmpdir({
  1466. init: async (dir) => {
  1467. await Filesystem.write(
  1468. path.join(dir, "kilo.json"),
  1469. JSON.stringify({
  1470. $schema: "https://app.kilo.ai/config.json",
  1471. agent: {
  1472. test: {
  1473. permission: {
  1474. glob: "allow",
  1475. },
  1476. tools: {
  1477. bash: true,
  1478. },
  1479. },
  1480. },
  1481. }),
  1482. )
  1483. },
  1484. })
  1485. await Instance.provide({
  1486. directory: tmp.path,
  1487. fn: async () => {
  1488. const config = await Config.get()
  1489. expect(config.agent?.["test"]?.permission).toEqual({
  1490. glob: "allow",
  1491. bash: "allow",
  1492. })
  1493. },
  1494. })
  1495. })
  1496. // kilocode_change start — isolate from global config to prevent cross-test contamination
  1497. // (migrateBashPermission may write permission.bash to a global config file created by other
  1498. // test files running in parallel, which mergeDeep then prepends to the project permission keys)
  1499. test("permission config preserves key order", async () => {
  1500. await using globalTmp = await tmpdir()
  1501. const prev = Global.Path.config
  1502. ;(Global.Path as { config: string }).config = globalTmp.path
  1503. await Config.invalidate(true)
  1504. try {
  1505. await using tmp = await tmpdir({
  1506. init: async (dir) => {
  1507. await Filesystem.write(
  1508. path.join(dir, "kilo.json"),
  1509. JSON.stringify({
  1510. $schema: "https://app.kilo.ai/config.json",
  1511. permission: {
  1512. "*": "deny",
  1513. edit: "ask",
  1514. write: "ask",
  1515. external_directory: "ask",
  1516. read: "allow",
  1517. todowrite: "allow",
  1518. "thoughts_*": "allow",
  1519. "reasoning_model_*": "allow",
  1520. "tools_*": "allow",
  1521. "pr_comments_*": "allow",
  1522. },
  1523. }),
  1524. )
  1525. },
  1526. })
  1527. await Instance.provide({
  1528. directory: tmp.path,
  1529. fn: async () => {
  1530. const config = await Config.get()
  1531. expect(Object.keys(config.permission!)).toEqual([
  1532. "*",
  1533. "edit",
  1534. "write",
  1535. "external_directory",
  1536. "read",
  1537. "todowrite",
  1538. "thoughts_*",
  1539. "reasoning_model_*",
  1540. "tools_*",
  1541. "pr_comments_*",
  1542. ])
  1543. },
  1544. })
  1545. } finally {
  1546. ;(Global.Path as { config: string }).config = prev
  1547. await Config.invalidate(true)
  1548. }
  1549. })
  1550. // kilocode_change end
  1551. // MCP config merging tests
  1552. test("project config can override MCP server enabled status", async () => {
  1553. await using tmp = await tmpdir({
  1554. init: async (dir) => {
  1555. // kilocode_change start — base config in .json, override in .jsonc (jsonc loads second and wins)
  1556. // Simulates a base config with disabled MCP
  1557. await Filesystem.write(
  1558. path.join(dir, "kilo.json"),
  1559. JSON.stringify({
  1560. $schema: "https://app.kilo.ai/config.json",
  1561. mcp: {
  1562. jira: {
  1563. type: "remote",
  1564. url: "https://jira.example.com/mcp",
  1565. enabled: false,
  1566. },
  1567. wiki: {
  1568. type: "remote",
  1569. url: "https://wiki.example.com/mcp",
  1570. enabled: false,
  1571. },
  1572. },
  1573. }),
  1574. )
  1575. // Override config enables just jira
  1576. await Filesystem.write(
  1577. path.join(dir, "kilo.jsonc"),
  1578. JSON.stringify({
  1579. $schema: "https://app.kilo.ai/config.json",
  1580. mcp: {
  1581. jira: {
  1582. type: "remote",
  1583. url: "https://jira.example.com/mcp",
  1584. enabled: true,
  1585. },
  1586. },
  1587. }),
  1588. )
  1589. // kilocode_change end
  1590. },
  1591. })
  1592. await Instance.provide({
  1593. directory: tmp.path,
  1594. fn: async () => {
  1595. const config = await Config.get()
  1596. // jira should be enabled (overridden by project config)
  1597. expect(config.mcp?.jira).toEqual({
  1598. type: "remote",
  1599. url: "https://jira.example.com/mcp",
  1600. enabled: true,
  1601. })
  1602. // wiki should still be disabled (not overridden)
  1603. expect(config.mcp?.wiki).toEqual({
  1604. type: "remote",
  1605. url: "https://wiki.example.com/mcp",
  1606. enabled: false,
  1607. })
  1608. },
  1609. })
  1610. })
  1611. test("MCP config deep merges preserving base config properties", async () => {
  1612. await using tmp = await tmpdir({
  1613. init: async (dir) => {
  1614. // kilocode_change start — base config in .json, override in .jsonc (jsonc loads second and wins)
  1615. // Base config with full MCP definition
  1616. await Filesystem.write(
  1617. path.join(dir, "kilo.json"),
  1618. JSON.stringify({
  1619. $schema: "https://app.kilo.ai/config.json",
  1620. mcp: {
  1621. myserver: {
  1622. type: "remote",
  1623. url: "https://myserver.example.com/mcp",
  1624. enabled: false,
  1625. headers: {
  1626. "X-Custom-Header": "value",
  1627. },
  1628. },
  1629. },
  1630. }),
  1631. )
  1632. // Override just enables it, should preserve other properties
  1633. // kilocode_change end
  1634. await Filesystem.write(
  1635. path.join(dir, "kilo.jsonc"),
  1636. JSON.stringify({
  1637. $schema: "https://app.kilo.ai/config.json",
  1638. mcp: {
  1639. myserver: {
  1640. type: "remote",
  1641. url: "https://myserver.example.com/mcp",
  1642. enabled: true,
  1643. },
  1644. },
  1645. }),
  1646. )
  1647. },
  1648. })
  1649. await Instance.provide({
  1650. directory: tmp.path,
  1651. fn: async () => {
  1652. const config = await Config.get()
  1653. expect(config.mcp?.myserver).toEqual({
  1654. type: "remote",
  1655. url: "https://myserver.example.com/mcp",
  1656. enabled: true,
  1657. headers: {
  1658. "X-Custom-Header": "value",
  1659. },
  1660. })
  1661. },
  1662. })
  1663. })
  1664. test("local .kilo config can override MCP from project config", async () => {
  1665. await using tmp = await tmpdir({
  1666. init: async (dir) => {
  1667. // Project config with disabled MCP
  1668. await Filesystem.write(
  1669. path.join(dir, "kilo.json"),
  1670. JSON.stringify({
  1671. $schema: "https://app.kilo.ai/config.json",
  1672. mcp: {
  1673. docs: {
  1674. type: "remote",
  1675. url: "https://docs.example.com/mcp",
  1676. enabled: false,
  1677. },
  1678. },
  1679. }),
  1680. )
  1681. // Local .kilo directory config enables it
  1682. const opencodeDir = path.join(dir, ".kilo")
  1683. await fs.mkdir(opencodeDir, { recursive: true })
  1684. await Filesystem.write(
  1685. path.join(opencodeDir, "kilo.json"),
  1686. JSON.stringify({
  1687. $schema: "https://app.kilo.ai/config.json",
  1688. mcp: {
  1689. docs: {
  1690. type: "remote",
  1691. url: "https://docs.example.com/mcp",
  1692. enabled: true,
  1693. },
  1694. },
  1695. }),
  1696. )
  1697. },
  1698. })
  1699. await Instance.provide({
  1700. directory: tmp.path,
  1701. fn: async () => {
  1702. const config = await Config.get()
  1703. expect(config.mcp?.docs?.enabled).toBe(true)
  1704. },
  1705. })
  1706. })
  1707. test("project config overrides remote well-known config", async () => {
  1708. const originalFetch = globalThis.fetch
  1709. let fetchedUrl: string | undefined
  1710. globalThis.fetch = mock((url: string | URL | Request) => {
  1711. const urlStr = url.toString()
  1712. if (urlStr.includes(".well-known/opencode")) {
  1713. fetchedUrl = urlStr
  1714. return Promise.resolve(
  1715. new Response(
  1716. JSON.stringify({
  1717. config: {
  1718. mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
  1719. },
  1720. }),
  1721. { status: 200 },
  1722. ),
  1723. )
  1724. }
  1725. return originalFetch(url)
  1726. }) as unknown as typeof fetch
  1727. const fakeAuth = Layer.mock(Auth.Service)({
  1728. all: () =>
  1729. Effect.succeed({
  1730. "https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
  1731. }),
  1732. })
  1733. const layer = Config.layer.pipe(
  1734. Layer.provide(AppFileSystem.defaultLayer),
  1735. Layer.provide(fakeAuth),
  1736. Layer.provide(emptyAccount),
  1737. Layer.provideMerge(infra),
  1738. )
  1739. try {
  1740. await provideTmpdirInstance(
  1741. () =>
  1742. Config.Service.use((svc) =>
  1743. Effect.gen(function* () {
  1744. const config = yield* svc.get()
  1745. expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
  1746. expect(config.mcp?.jira?.enabled).toBe(true)
  1747. }),
  1748. ),
  1749. {
  1750. git: true,
  1751. config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
  1752. },
  1753. ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
  1754. } finally {
  1755. globalThis.fetch = originalFetch
  1756. }
  1757. })
  1758. test("wellknown URL with trailing slash is normalized", async () => {
  1759. const originalFetch = globalThis.fetch
  1760. let fetchedUrl: string | undefined
  1761. globalThis.fetch = mock((url: string | URL | Request) => {
  1762. const urlStr = url.toString()
  1763. if (urlStr.includes(".well-known/opencode")) {
  1764. fetchedUrl = urlStr
  1765. return Promise.resolve(
  1766. new Response(
  1767. JSON.stringify({
  1768. config: {
  1769. mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } },
  1770. },
  1771. }),
  1772. { status: 200 },
  1773. ),
  1774. )
  1775. }
  1776. return originalFetch(url)
  1777. }) as unknown as typeof fetch
  1778. const fakeAuth = Layer.mock(Auth.Service)({
  1779. all: () =>
  1780. Effect.succeed({
  1781. "https://example.com/": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
  1782. }),
  1783. })
  1784. const layer = Config.layer.pipe(
  1785. Layer.provide(AppFileSystem.defaultLayer),
  1786. Layer.provide(fakeAuth),
  1787. Layer.provide(emptyAccount),
  1788. Layer.provideMerge(infra),
  1789. )
  1790. try {
  1791. await provideTmpdirInstance(
  1792. () =>
  1793. Config.Service.use((svc) =>
  1794. Effect.gen(function* () {
  1795. yield* svc.get()
  1796. expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
  1797. }),
  1798. ),
  1799. { git: true },
  1800. ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
  1801. } finally {
  1802. globalThis.fetch = originalFetch
  1803. }
  1804. })
  1805. describe("resolvePluginSpec", () => {
  1806. test("keeps package specs unchanged", async () => {
  1807. await using tmp = await tmpdir()
  1808. const file = path.join(tmp.path, "kilo.json")
  1809. expect(await Config.resolvePluginSpec("[email protected]", file)).toBe("[email protected]")
  1810. expect(await Config.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
  1811. })
  1812. test("resolves windows-style relative plugin directory specs", async () => {
  1813. if (process.platform !== "win32") return
  1814. await using tmp = await tmpdir({
  1815. init: async (dir) => {
  1816. const plugin = path.join(dir, "plugin")
  1817. await fs.mkdir(plugin, { recursive: true })
  1818. await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
  1819. },
  1820. })
  1821. const file = path.join(tmp.path, "opencode.json")
  1822. const hit = await Config.resolvePluginSpec(".\\plugin", file)
  1823. expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
  1824. })
  1825. test("resolves relative file plugin paths to file urls", async () => {
  1826. await using tmp = await tmpdir({
  1827. init: async (dir) => {
  1828. await Filesystem.write(path.join(dir, "plugin.ts"), "export default {}")
  1829. },
  1830. })
  1831. const file = path.join(tmp.path, "kilo.json")
  1832. const hit = await Config.resolvePluginSpec("./plugin.ts", file)
  1833. expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href)
  1834. })
  1835. test("resolves plugin directory paths to directory urls", async () => {
  1836. await using tmp = await tmpdir({
  1837. init: async (dir) => {
  1838. const plugin = path.join(dir, "plugin")
  1839. await fs.mkdir(plugin, { recursive: true })
  1840. await Filesystem.writeJson(path.join(plugin, "package.json"), {
  1841. name: "demo-plugin",
  1842. type: "module",
  1843. main: "./index.ts",
  1844. })
  1845. await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
  1846. },
  1847. })
  1848. const file = path.join(tmp.path, "kilo.json")
  1849. const hit = await Config.resolvePluginSpec("./plugin", file)
  1850. expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href)
  1851. })
  1852. test("resolves plugin directories without package.json to index.ts", async () => {
  1853. await using tmp = await tmpdir({
  1854. init: async (dir) => {
  1855. const plugin = path.join(dir, "plugin")
  1856. await fs.mkdir(plugin, { recursive: true })
  1857. await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
  1858. },
  1859. })
  1860. const file = path.join(tmp.path, "opencode.json")
  1861. const hit = await Config.resolvePluginSpec("./plugin", file)
  1862. expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
  1863. })
  1864. })
  1865. describe("deduplicatePluginOrigins", () => {
  1866. const dedupe = (plugins: Config.PluginSpec[]) =>
  1867. Config.deduplicatePluginOrigins(
  1868. plugins.map((spec) => ({
  1869. spec,
  1870. source: "",
  1871. scope: "global" as const,
  1872. })),
  1873. ).map((item) => item.spec)
  1874. test("removes duplicates keeping higher priority (later entries)", () => {
  1875. const plugins = ["[email protected]", "[email protected]", "[email protected]", "[email protected]"]
  1876. const result = dedupe(plugins)
  1877. expect(result).toContain("[email protected]")
  1878. expect(result).toContain("[email protected]")
  1879. expect(result).toContain("[email protected]")
  1880. expect(result).not.toContain("[email protected]")
  1881. expect(result.length).toBe(3)
  1882. })
  1883. test("keeps path plugins separate from package plugins", () => {
  1884. const plugins = ["[email protected]", "file:///project/.kilo/plugin/oh-my-opencode.js"]
  1885. const result = dedupe(plugins)
  1886. expect(result).toEqual(plugins)
  1887. })
  1888. test("deduplicates direct path plugins by exact spec", () => {
  1889. const plugins = ["file:///project/.kilo/plugin/demo.ts", "file:///project/.kilo/plugin/demo.ts"]
  1890. const result = dedupe(plugins)
  1891. expect(result).toEqual(["file:///project/.kilo/plugin/demo.ts"])
  1892. })
  1893. test("preserves order of remaining plugins", () => {
  1894. const plugins = ["[email protected]", "[email protected]", "[email protected]"]
  1895. const result = dedupe(plugins)
  1896. expect(result).toEqual(["[email protected]", "[email protected]", "[email protected]"])
  1897. })
  1898. test("loads auto-discovered local plugins as file urls", async () => {
  1899. await using tmp = await tmpdir({
  1900. init: async (dir) => {
  1901. const projectDir = path.join(dir, "project")
  1902. const opencodeDir = path.join(projectDir, ".kilo")
  1903. const pluginDir = path.join(opencodeDir, "plugin")
  1904. await fs.mkdir(pluginDir, { recursive: true })
  1905. await Filesystem.write(
  1906. path.join(dir, "kilo.json"),
  1907. JSON.stringify({
  1908. $schema: "https://app.kilo.ai/config.json",
  1909. plugin: ["[email protected]"],
  1910. }),
  1911. )
  1912. await Filesystem.write(path.join(pluginDir, "my-plugin.js"), "export default {}")
  1913. },
  1914. })
  1915. await Instance.provide({
  1916. directory: path.join(tmp.path, "project"),
  1917. fn: async () => {
  1918. const config = await Config.get()
  1919. const plugins = config.plugin ?? []
  1920. expect(plugins.some((p) => Config.pluginSpecifier(p) === "[email protected]")).toBe(true)
  1921. expect(plugins.some((p) => Config.pluginSpecifier(p).startsWith("file://"))).toBe(true)
  1922. },
  1923. })
  1924. })
  1925. })
  1926. describe("KILO_DISABLE_PROJECT_CONFIG", () => {
  1927. test("skips project config files when flag is set", async () => {
  1928. const originalEnv = process.env["KILO_DISABLE_PROJECT_CONFIG"]
  1929. process.env["KILO_DISABLE_PROJECT_CONFIG"] = "true"
  1930. try {
  1931. await using tmp = await tmpdir({
  1932. init: async (dir) => {
  1933. // Create a project config that would normally be loaded
  1934. await Filesystem.write(
  1935. path.join(dir, "kilo.json"),
  1936. JSON.stringify({
  1937. $schema: "https://app.kilo.ai/config.json",
  1938. model: "project/model",
  1939. username: "project-user",
  1940. }),
  1941. )
  1942. },
  1943. })
  1944. await Instance.provide({
  1945. directory: tmp.path,
  1946. fn: async () => {
  1947. const config = await Config.get()
  1948. // Project config should NOT be loaded - model should be default, not "project/model"
  1949. expect(config.model).not.toBe("project/model")
  1950. expect(config.username).not.toBe("project-user")
  1951. },
  1952. })
  1953. } finally {
  1954. if (originalEnv === undefined) {
  1955. delete process.env["KILO_DISABLE_PROJECT_CONFIG"]
  1956. } else {
  1957. process.env["KILO_DISABLE_PROJECT_CONFIG"] = originalEnv
  1958. }
  1959. }
  1960. })
  1961. // kilocode_change start
  1962. test("skips project .kilo/ directories when flag is set", async () => {
  1963. // kilocode_change end
  1964. const originalEnv = process.env["KILO_DISABLE_PROJECT_CONFIG"]
  1965. process.env["KILO_DISABLE_PROJECT_CONFIG"] = "true"
  1966. try {
  1967. await using tmp = await tmpdir({
  1968. init: async (dir) => {
  1969. // Create a .kilo directory with a command
  1970. const opencodeDir = path.join(dir, ".kilo", "command")
  1971. await fs.mkdir(opencodeDir, { recursive: true })
  1972. await Filesystem.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.")
  1973. },
  1974. })
  1975. await Instance.provide({
  1976. directory: tmp.path,
  1977. fn: async () => {
  1978. const directories = await Config.directories()
  1979. // Project .kilo should NOT be in directories list
  1980. const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path))
  1981. expect(hasProjectOpencode).toBe(false)
  1982. },
  1983. })
  1984. } finally {
  1985. if (originalEnv === undefined) {
  1986. delete process.env["KILO_DISABLE_PROJECT_CONFIG"]
  1987. } else {
  1988. process.env["KILO_DISABLE_PROJECT_CONFIG"] = originalEnv
  1989. }
  1990. }
  1991. })
  1992. test("still loads global config when flag is set", async () => {
  1993. const originalEnv = process.env["KILO_DISABLE_PROJECT_CONFIG"]
  1994. process.env["KILO_DISABLE_PROJECT_CONFIG"] = "true"
  1995. try {
  1996. await using tmp = await tmpdir()
  1997. await Instance.provide({
  1998. directory: tmp.path,
  1999. fn: async () => {
  2000. // Should still get default config (from global or defaults)
  2001. const config = await Config.get()
  2002. expect(config).toBeDefined()
  2003. expect(config.username).toBeDefined()
  2004. },
  2005. })
  2006. } finally {
  2007. if (originalEnv === undefined) {
  2008. delete process.env["KILO_DISABLE_PROJECT_CONFIG"]
  2009. } else {
  2010. process.env["KILO_DISABLE_PROJECT_CONFIG"] = originalEnv
  2011. }
  2012. }
  2013. })
  2014. test("skips relative instructions with warning when flag is set but no config dir", async () => {
  2015. const originalDisable = process.env["KILO_DISABLE_PROJECT_CONFIG"]
  2016. const originalConfigDir = process.env["KILO_CONFIG_DIR"]
  2017. try {
  2018. // Ensure no config dir is set
  2019. delete process.env["KILO_CONFIG_DIR"]
  2020. process.env["KILO_DISABLE_PROJECT_CONFIG"] = "true"
  2021. await using tmp = await tmpdir({
  2022. init: async (dir) => {
  2023. // Create a config with relative instruction path
  2024. await Filesystem.write(
  2025. path.join(dir, "kilo.json"),
  2026. JSON.stringify({
  2027. $schema: "https://app.kilo.ai/config.json",
  2028. instructions: ["./CUSTOM.md"],
  2029. }),
  2030. )
  2031. // Create the instruction file (should be skipped)
  2032. await Filesystem.write(path.join(dir, "CUSTOM.md"), "# Custom Instructions")
  2033. },
  2034. })
  2035. await Instance.provide({
  2036. directory: tmp.path,
  2037. fn: async () => {
  2038. // The relative instruction should be skipped without error
  2039. // We're mainly verifying this doesn't throw and the config loads
  2040. const config = await Config.get()
  2041. expect(config).toBeDefined()
  2042. // The instruction should have been skipped (warning logged)
  2043. // We can't easily test the warning was logged, but we verify
  2044. // the relative path didn't cause an error
  2045. },
  2046. })
  2047. } finally {
  2048. if (originalDisable === undefined) {
  2049. delete process.env["KILO_DISABLE_PROJECT_CONFIG"]
  2050. } else {
  2051. process.env["KILO_DISABLE_PROJECT_CONFIG"] = originalDisable
  2052. }
  2053. if (originalConfigDir === undefined) {
  2054. delete process.env["KILO_CONFIG_DIR"]
  2055. } else {
  2056. process.env["KILO_CONFIG_DIR"] = originalConfigDir
  2057. }
  2058. }
  2059. })
  2060. test("KILO_CONFIG_DIR still works when flag is set", async () => {
  2061. const originalDisable = process.env["KILO_DISABLE_PROJECT_CONFIG"]
  2062. const originalConfigDir = process.env["KILO_CONFIG_DIR"]
  2063. try {
  2064. await using configDirTmp = await tmpdir({
  2065. init: async (dir) => {
  2066. // Create config in the custom config dir
  2067. await Filesystem.write(
  2068. path.join(dir, "kilo.json"),
  2069. JSON.stringify({
  2070. $schema: "https://app.kilo.ai/config.json",
  2071. model: "configdir/model",
  2072. }),
  2073. )
  2074. },
  2075. })
  2076. await using projectTmp = await tmpdir({
  2077. init: async (dir) => {
  2078. // Create config in project (should be ignored)
  2079. await Filesystem.write(
  2080. path.join(dir, "kilo.json"),
  2081. JSON.stringify({
  2082. $schema: "https://app.kilo.ai/config.json",
  2083. model: "project/model",
  2084. }),
  2085. )
  2086. },
  2087. })
  2088. process.env["KILO_DISABLE_PROJECT_CONFIG"] = "true"
  2089. process.env["KILO_CONFIG_DIR"] = configDirTmp.path
  2090. await Instance.provide({
  2091. directory: projectTmp.path,
  2092. fn: async () => {
  2093. const config = await Config.get()
  2094. // Should load from KILO_CONFIG_DIR, not project
  2095. expect(config.model).toBe("configdir/model")
  2096. },
  2097. })
  2098. } finally {
  2099. if (originalDisable === undefined) {
  2100. delete process.env["KILO_DISABLE_PROJECT_CONFIG"]
  2101. } else {
  2102. process.env["KILO_DISABLE_PROJECT_CONFIG"] = originalDisable
  2103. }
  2104. if (originalConfigDir === undefined) {
  2105. delete process.env["KILO_CONFIG_DIR"]
  2106. } else {
  2107. process.env["KILO_CONFIG_DIR"] = originalConfigDir
  2108. }
  2109. }
  2110. })
  2111. })
  2112. describe("KILO_CONFIG_CONTENT token substitution", () => {
  2113. test("substitutes {env:} tokens in KILO_CONFIG_CONTENT", async () => {
  2114. const originalEnv = process.env["KILO_CONFIG_CONTENT"]
  2115. const originalTestVar = process.env["TEST_CONFIG_VAR"]
  2116. process.env["TEST_CONFIG_VAR"] = "test_api_key_12345"
  2117. process.env["KILO_CONFIG_CONTENT"] = JSON.stringify({
  2118. $schema: "https://opencode.ai/config.json",
  2119. username: "{env:TEST_CONFIG_VAR}",
  2120. })
  2121. try {
  2122. await using tmp = await tmpdir()
  2123. await Instance.provide({
  2124. directory: tmp.path,
  2125. fn: async () => {
  2126. const config = await Config.get()
  2127. expect(config.username).toBe("test_api_key_12345")
  2128. },
  2129. })
  2130. } finally {
  2131. if (originalEnv !== undefined) {
  2132. process.env["KILO_CONFIG_CONTENT"] = originalEnv
  2133. } else {
  2134. delete process.env["KILO_CONFIG_CONTENT"]
  2135. }
  2136. if (originalTestVar !== undefined) {
  2137. process.env["TEST_CONFIG_VAR"] = originalTestVar
  2138. } else {
  2139. delete process.env["TEST_CONFIG_VAR"]
  2140. }
  2141. }
  2142. })
  2143. test("substitutes {file:} tokens in KILO_CONFIG_CONTENT", async () => {
  2144. const originalEnv = process.env["KILO_CONFIG_CONTENT"]
  2145. try {
  2146. await using tmp = await tmpdir({
  2147. init: async (dir) => {
  2148. await Filesystem.write(path.join(dir, "api_key.txt"), "secret_key_from_file")
  2149. process.env["KILO_CONFIG_CONTENT"] = JSON.stringify({
  2150. $schema: "https://opencode.ai/config.json",
  2151. username: "{file:./api_key.txt}",
  2152. })
  2153. },
  2154. })
  2155. await Instance.provide({
  2156. directory: tmp.path,
  2157. fn: async () => {
  2158. const config = await Config.get()
  2159. expect(config.username).toBe("secret_key_from_file")
  2160. },
  2161. })
  2162. } finally {
  2163. if (originalEnv !== undefined) {
  2164. process.env["KILO_CONFIG_CONTENT"] = originalEnv
  2165. } else {
  2166. delete process.env["KILO_CONFIG_CONTENT"]
  2167. }
  2168. }
  2169. })
  2170. })
  2171. // parseManagedPlist unit tests — pure function, no OS interaction
  2172. test("parseManagedPlist strips MDM metadata keys", async () => {
  2173. const config = await Config.parseManagedPlist(
  2174. JSON.stringify({
  2175. PayloadDisplayName: "OpenCode Managed",
  2176. PayloadIdentifier: "ai.opencode.managed.test",
  2177. PayloadType: "ai.opencode.managed",
  2178. PayloadUUID: "AAAA-BBBB-CCCC",
  2179. PayloadVersion: 1,
  2180. _manualProfile: true,
  2181. share: "disabled",
  2182. model: "mdm/model",
  2183. }),
  2184. "test:mobileconfig",
  2185. )
  2186. expect(config.share).toBe("disabled")
  2187. expect(config.model).toBe("mdm/model")
  2188. // MDM keys must not leak into the parsed config
  2189. expect((config as any).PayloadUUID).toBeUndefined()
  2190. expect((config as any).PayloadType).toBeUndefined()
  2191. expect((config as any)._manualProfile).toBeUndefined()
  2192. })
  2193. test("parseManagedPlist parses server settings", async () => {
  2194. const config = await Config.parseManagedPlist(
  2195. JSON.stringify({
  2196. $schema: "https://opencode.ai/config.json",
  2197. server: { hostname: "127.0.0.1", mdns: false },
  2198. autoupdate: true,
  2199. }),
  2200. "test:mobileconfig",
  2201. )
  2202. expect(config.server?.hostname).toBe("127.0.0.1")
  2203. expect(config.server?.mdns).toBe(false)
  2204. expect(config.autoupdate).toBe(true)
  2205. })
  2206. test("parseManagedPlist parses permission rules", async () => {
  2207. const config = await Config.parseManagedPlist(
  2208. JSON.stringify({
  2209. $schema: "https://opencode.ai/config.json",
  2210. permission: {
  2211. "*": "ask",
  2212. bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
  2213. grep: "allow",
  2214. glob: "allow",
  2215. webfetch: "ask",
  2216. "~/.ssh/*": "deny",
  2217. },
  2218. }),
  2219. "test:mobileconfig",
  2220. )
  2221. expect(config.permission?.["*"]).toBe("ask")
  2222. expect(config.permission?.grep).toBe("allow")
  2223. expect(config.permission?.webfetch).toBe("ask")
  2224. expect(config.permission?.["~/.ssh/*"]).toBe("deny")
  2225. const bash = config.permission?.bash as Record<string, string>
  2226. expect(bash?.["rm -rf *"]).toBe("deny")
  2227. expect(bash?.["curl *"]).toBe("deny")
  2228. })
  2229. test("parseManagedPlist parses enabled_providers", async () => {
  2230. const config = await Config.parseManagedPlist(
  2231. JSON.stringify({
  2232. $schema: "https://opencode.ai/config.json",
  2233. enabled_providers: ["anthropic", "google"],
  2234. }),
  2235. "test:mobileconfig",
  2236. )
  2237. expect(config.enabled_providers).toEqual(["anthropic", "google"])
  2238. })
  2239. test("parseManagedPlist handles empty config", async () => {
  2240. const config = await Config.parseManagedPlist(
  2241. JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
  2242. "test:mobileconfig",
  2243. )
  2244. expect(config.$schema).toBe("https://opencode.ai/config.json")
  2245. })