config.test.ts 65 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052
  1. import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test"
  2. import { ConfigV1 } from "@kirincode-ai/core/v1/config/config"
  3. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  4. import { httpClient } from "@kirincode-ai/core/effect/app-node-platform"
  5. import { Cause, Effect, Exit, Layer, Option } from "effect"
  6. import { NamedError } from "@kirincode-ai/core/util/error"
  7. import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
  8. import { Config } from "@/config/config"
  9. import { ConfigManaged } from "@/config/managed"
  10. import { ConfigParse } from "../../src/config/parse"
  11. import { Npm } from "@kirincode-ai/core/npm"
  12. import { InstanceRef } from "../../src/effect/instance-ref"
  13. import type { InstanceContext } from "../../src/project/instance-context"
  14. import { Auth } from "../../src/auth"
  15. import { Account } from "../../src/account/account"
  16. import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
  17. import { FSUtil } from "@kirincode-ai/core/fs-util"
  18. import { Env } from "../../src/env"
  19. import {
  20. provideTmpdirInstance,
  21. TestInstance,
  22. tmpdir,
  23. tmpdirScoped,
  24. withTestInstance,
  25. provideInstanceEffect,
  26. testInstanceStoreLayer,
  27. } from "../fixture/fixture"
  28. import { InstanceRuntime } from "@/project/instance-runtime"
  29. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  30. import { testEffect } from "../lib/effect"
  31. import path from "path"
  32. import fs from "fs/promises"
  33. import os from "os"
  34. import { pathToFileURL } from "url"
  35. import { Global } from "@kirincode-ai/core/global"
  36. import { ProjectV2 } from "@kirincode-ai/core/project"
  37. import { Filesystem } from "@/util/filesystem"
  38. import { ConfigPlugin } from "@/config/plugin"
  39. import { ConfigPluginV1 } from "@kirincode-ai/core/v1/config/plugin"
  40. import { AccountTest } from "../fake/account"
  41. import { AuthTest } from "../fake/auth"
  42. import { NpmTest } from "../fake/npm"
  43. const unexpectedHttp = HttpClient.make((request) =>
  44. Effect.die(`unexpected http request: ${request.method} ${request.url}`),
  45. )
  46. const json = (request: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
  47. HttpClientResponse.fromWeb(
  48. request,
  49. new Response(JSON.stringify(body), {
  50. status,
  51. headers: { "content-type": "application/json" },
  52. }),
  53. )
  54. const wellKnownAuth = (url: string) =>
  55. Layer.mock(Auth.Service)({
  56. all: () =>
  57. Effect.succeed({
  58. [url]: new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
  59. }),
  60. })
  61. function remoteConfigClient(input: {
  62. wellKnown: unknown
  63. remote?: unknown
  64. remoteHtml?: string
  65. seen: { wellKnown?: string; remote?: string; authorization?: string }
  66. }) {
  67. return HttpClient.make((request) => {
  68. if (request.url.includes(".well-known/opencode")) {
  69. input.seen.wellKnown = request.url
  70. return Effect.succeed(json(request, input.wellKnown))
  71. }
  72. if (request.url.includes("config.example.com") && (input.remote !== undefined || input.remoteHtml !== undefined)) {
  73. input.seen.remote = request.url
  74. input.seen.authorization = request.headers.authorization
  75. if (input.remoteHtml !== undefined) {
  76. return Effect.succeed(
  77. HttpClientResponse.fromWeb(
  78. request,
  79. new Response(input.remoteHtml, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } }),
  80. ),
  81. )
  82. }
  83. return Effect.succeed(json(request, input.remote))
  84. }
  85. return Effect.succeed(json(request, {}, 404))
  86. })
  87. }
  88. const configLayer = (
  89. options: {
  90. auth?: Layer.Layer<Auth.Service>
  91. account?: Layer.Layer<Account.Service>
  92. client?: HttpClient.HttpClient
  93. } = {},
  94. ) =>
  95. LayerNode.compile(LayerNode.group([Config.node, FSUtil.node, Env.node, CrossSpawnSpawner.node]), [
  96. [Auth.node, options.auth ?? AuthTest.empty],
  97. [Account.node, options.account ?? AccountTest.empty],
  98. [Npm.node, NpmTest.noop],
  99. [httpClient, Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)],
  100. ])
  101. const layer = configLayer()
  102. const it = testEffect(layer)
  103. const configIt = (options?: Parameters<typeof configLayer>[0]) => testEffect(configLayer(options))
  104. const schemaConfig = (config: object) => ({ $schema: "https://kirincode.ai/config.json", ...config })
  105. const provideCurrentInstance = <A, E, R>(effect: Effect.Effect<A, E, R>, ctx: InstanceContext) =>
  106. effect.pipe(Effect.provideService(InstanceRef, ctx))
  107. const load = (ctx: InstanceContext) =>
  108. Effect.runPromise(
  109. Config.Service.use((svc) => provideCurrentInstance(svc.get(), ctx)).pipe(Effect.scoped, Effect.provide(layer)),
  110. )
  111. const clearEffect = (wait = false) =>
  112. Config.use
  113. .invalidate()
  114. .pipe(
  115. Effect.scoped,
  116. Effect.provide(layer),
  117. Effect.andThen(wait ? Effect.promise(() => InstanceRuntime.disposeAllInstances()) : Effect.void),
  118. )
  119. const clear = (wait = false) => Effect.runPromise(clearEffect(wait))
  120. // Get managed config directory from environment (set in preload.ts)
  121. const managedConfigDir = process.env.KIRINCODE_TEST_MANAGED_CONFIG_DIR!
  122. const originalTestToken = process.env.TEST_TOKEN
  123. const originalConsoleToken = process.env.KIRINCODE_CONSOLE_TOKEN
  124. beforeEach(async () => {
  125. await clear(true)
  126. })
  127. afterEach(async () => {
  128. await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
  129. if (originalTestToken === undefined) delete process.env.TEST_TOKEN
  130. else process.env.TEST_TOKEN = originalTestToken
  131. if (originalConsoleToken === undefined) delete process.env.KIRINCODE_CONSOLE_TOKEN
  132. else process.env.KIRINCODE_CONSOLE_TOKEN = originalConsoleToken
  133. await clear(true)
  134. })
  135. const writeManagedSettingsEffect = (settings: object, filename?: string) =>
  136. FSUtil.use.writeWithDirs(path.join(managedConfigDir, filename ?? "kirincode.json"), JSON.stringify(settings))
  137. async function writeConfig(dir: string, config: object, name = "kirincode.json") {
  138. await Filesystem.write(path.join(dir, name), JSON.stringify(config))
  139. }
  140. const writeConfigEffect = (dir: string, config: object, name = "kirincode.json") =>
  141. FSUtil.use.writeWithDirs(path.join(dir, name), JSON.stringify(config))
  142. const withInstanceDir = <A, E, R>(dir: string, effect: Effect.Effect<A, E, R>) =>
  143. effect.pipe(
  144. Effect.provideService(TestInstance, { directory: dir }),
  145. provideInstanceEffect(dir),
  146. Effect.provide(testInstanceStoreLayer),
  147. Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)),
  148. )
  149. const withGlobalConfigDir = <A, E, R>(dir: string, effect: Effect.Effect<A, E, R>) =>
  150. Effect.acquireUseRelease(
  151. Effect.gen(function* () {
  152. const previous = Global.Path.config
  153. ;(Global.Path as { config: string }).config = dir
  154. yield* clearEffect(true)
  155. return previous
  156. }),
  157. () => effect,
  158. (previous) =>
  159. Effect.gen(function* () {
  160. ;(Global.Path as { config: string }).config = previous
  161. yield* clearEffect(true)
  162. }),
  163. )
  164. const withGlobalConfig = <A, E, R>(
  165. input: { config?: object; name?: string },
  166. fn: (input: { dir: string }) => Effect.Effect<A, E, R>,
  167. ) =>
  168. Effect.gen(function* () {
  169. const dir = yield* tmpdirScoped()
  170. if (input.config) yield* writeConfigEffect(dir, schemaConfig(input.config), input.name)
  171. return yield* withGlobalConfigDir(dir, fn({ dir }))
  172. })
  173. const withConfigTree = <A, E, R>(
  174. input: { global?: object; project?: object; local?: object },
  175. effect: Effect.Effect<A, E, R>,
  176. ) =>
  177. Effect.gen(function* () {
  178. const root = yield* tmpdirScoped()
  179. const global = yield* tmpdirScoped()
  180. const directory = path.join(root, "project")
  181. yield* Effect.all(
  182. [
  183. input.global ? writeConfigEffect(global, schemaConfig(input.global)) : undefined,
  184. input.project ? writeConfigEffect(directory, schemaConfig(input.project)) : undefined,
  185. input.local ? writeConfigEffect(path.join(directory, ".kirincode"), schemaConfig(input.local)) : undefined,
  186. ].filter((effect): effect is Effect.Effect<void, FSUtil.Error, FSUtil.Service> => effect !== undefined),
  187. { concurrency: "unbounded" },
  188. )
  189. return yield* withGlobalConfigDir(global, withInstanceDir(directory, effect))
  190. })
  191. const wellKnown = (input: {
  192. authUrl?: string
  193. config?: unknown
  194. remoteConfig?: { url: string; headers?: Record<string, string> }
  195. remote?: unknown
  196. remoteHtml?: string
  197. wellKnown?: unknown
  198. }) => {
  199. const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
  200. const client = remoteConfigClient({
  201. seen,
  202. wellKnown: input.wellKnown ?? {
  203. ...(input.config !== undefined ? { config: input.config } : {}),
  204. ...(input.remoteConfig !== undefined ? { remote_config: input.remoteConfig } : {}),
  205. },
  206. remote: input.remote,
  207. remoteHtml: input.remoteHtml,
  208. })
  209. return {
  210. seen,
  211. it: configIt({ auth: wellKnownAuth(input.authUrl ?? "https://example.com"), client }),
  212. }
  213. }
  214. function withProcessEnv<A, E, R>(key: string, value: string | undefined, effect: Effect.Effect<A, E, R>) {
  215. return withProcessEnvs({ [key]: value }, effect)
  216. }
  217. function withProcessEnvs<A, E, R>(entries: Record<string, string | undefined>, effect: Effect.Effect<A, E, R>) {
  218. return Effect.acquireUseRelease(
  219. Effect.sync(() => {
  220. const originals: Record<string, string | undefined> = {}
  221. for (const [key, value] of Object.entries(entries)) {
  222. originals[key] = process.env[key]
  223. if (value === undefined) delete process.env[key]
  224. else process.env[key] = value
  225. }
  226. return originals
  227. }),
  228. () => effect,
  229. (originals) =>
  230. Effect.sync(() => {
  231. for (const [key, original] of Object.entries(originals)) {
  232. if (original !== undefined) process.env[key] = original
  233. else delete process.env[key]
  234. }
  235. }),
  236. )
  237. }
  238. async function check(map: (dir: string) => string) {
  239. if (process.platform !== "win32") return
  240. await using globalTmp = await tmpdir()
  241. await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
  242. const prev = Global.Path.config
  243. ;(Global.Path as { config: string }).config = globalTmp.path
  244. await clear()
  245. try {
  246. await writeConfig(globalTmp.path, {
  247. $schema: "https://kirincode.ai/config.json",
  248. snapshot: false,
  249. })
  250. await withTestInstance({
  251. directory: map(tmp.path),
  252. fn: async (ctx) => {
  253. const cfg = await load(ctx)
  254. expect(cfg.snapshot).toBe(true)
  255. expect(ctx.directory).toBe(Filesystem.resolve(tmp.path))
  256. expect(ctx.project.id).not.toBe(ProjectV2.ID.global)
  257. },
  258. })
  259. } finally {
  260. await InstanceRuntime.disposeAllInstances()
  261. ;(Global.Path as { config: string }).config = prev
  262. await clear()
  263. }
  264. }
  265. it.instance("loads config with defaults when no files exist", () =>
  266. Effect.gen(function* () {
  267. const config = yield* Config.use.get()
  268. expect(config.username).toBeDefined()
  269. }),
  270. )
  271. it.instance("falls back to generic username when system user info is unavailable", () =>
  272. Effect.gen(function* () {
  273. const userInfo = spyOn(os, "userInfo").mockImplementation(() => {
  274. throw Object.assign(new Error("missing passwd entry"), { code: "ENOENT" })
  275. })
  276. try {
  277. const config = yield* Config.use.get()
  278. expect(config.username).toBe("user")
  279. } finally {
  280. userInfo.mockRestore()
  281. }
  282. }),
  283. )
  284. it.effect("creates global jsonc config with schema when no global configs exist", () =>
  285. withGlobalConfig({}, ({ dir }) =>
  286. Effect.gen(function* () {
  287. yield* Config.use.get().pipe(provideInstanceEffect(dir))
  288. const content = yield* FSUtil.use.readFileString(path.join(dir, "kirincode.jsonc"))
  289. expect(content).toContain('"$schema": "https://kirincode.ai/config.json"')
  290. }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
  291. ),
  292. )
  293. it.effect("does not create global config when KIRINCODE_CONFIG_DIR is set", () =>
  294. Effect.gen(function* () {
  295. const custom = yield* tmpdirScoped()
  296. yield* withGlobalConfig({}, ({ dir }) =>
  297. withProcessEnv(
  298. "KIRINCODE_CONFIG_DIR",
  299. custom,
  300. Effect.gen(function* () {
  301. yield* Config.use.get().pipe(provideInstanceEffect(dir))
  302. expect(yield* FSUtil.use.existsSafe(path.join(dir, "kirincode.jsonc"))).toBe(false)
  303. }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
  304. ),
  305. )
  306. }),
  307. )
  308. it.instance(
  309. "loads JSON config file",
  310. Effect.gen(function* () {
  311. const config = yield* Config.use.get()
  312. expect(config.model).toBe("test/model")
  313. expect(config.username).toBe("testuser")
  314. }),
  315. { config: { model: "test/model", username: "testuser" } },
  316. )
  317. it.instance(
  318. "loads shell config field",
  319. Effect.gen(function* () {
  320. const config = yield* Config.use.get()
  321. expect(config.shell).toBe("bash")
  322. }),
  323. { config: { shell: "bash" } },
  324. )
  325. it.instance("updates config and preserves empty shell sentinel", () =>
  326. Effect.gen(function* () {
  327. const test = yield* TestInstance
  328. yield* writeConfigEffect(
  329. test.directory,
  330. { $schema: "https://kirincode.ai/config.json", shell: "bash" },
  331. "config.json",
  332. )
  333. yield* Config.Service.use((svc) => svc.update(ConfigParse.schema(ConfigV1.Info, { shell: "" }, "test:config")))
  334. const writtenConfig = yield* FSUtil.use.readJson(path.join(test.directory, "config.json"))
  335. expect(writtenConfig).toMatchObject({ shell: "" })
  336. }),
  337. )
  338. it.effect("updates global config and omits empty shell key in json", () =>
  339. withGlobalConfig({ config: { shell: "bash" } }, ({ dir }) =>
  340. Effect.gen(function* () {
  341. yield* Config.use.updateGlobal({ shell: "" })
  342. const writtenConfig = yield* FSUtil.use.readJson(path.join(dir, "kirincode.json"))
  343. expect(writtenConfig).not.toHaveProperty("shell")
  344. }),
  345. ),
  346. )
  347. it.effect("updates global config and omits empty shell key in jsonc", () =>
  348. withGlobalConfig({ config: { shell: "bash", model: "test/model" }, name: "kirincode.jsonc" }, ({ dir }) =>
  349. Effect.gen(function* () {
  350. yield* Config.use.updateGlobal({ shell: "" })
  351. const file = path.join(dir, "kirincode.jsonc")
  352. const writtenConfig = yield* FSUtil.use.readFileString(file)
  353. const parsed = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(writtenConfig, file), file)
  354. expect(writtenConfig).not.toContain('"shell"')
  355. expect(parsed.shell).toBeUndefined()
  356. expect(parsed.model).toBe("test/model")
  357. }),
  358. ),
  359. )
  360. it.instance(
  361. "loads formatter boolean config",
  362. Effect.gen(function* () {
  363. const config = yield* Config.use.get()
  364. expect(config.formatter).toBe(true)
  365. }),
  366. { config: { formatter: true } },
  367. )
  368. it.instance(
  369. "loads lsp boolean config",
  370. Effect.gen(function* () {
  371. const config = yield* Config.use.get()
  372. expect(config.lsp).toBe(true)
  373. }),
  374. { config: { lsp: true } },
  375. )
  376. test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
  377. // Git Bash and MSYS2 both use /<drive>/... paths on Windows.
  378. await check((dir) => {
  379. const drive = dir[0].toLowerCase()
  380. const rest = dir.slice(2).replaceAll("\\", "/")
  381. return `/${drive}${rest}`
  382. })
  383. })
  384. test("loads project config from Cygwin paths on Windows", async () => {
  385. await check((dir) => {
  386. const drive = dir[0].toLowerCase()
  387. const rest = dir.slice(2).replaceAll("\\", "/")
  388. return `/cygdrive/${drive}${rest}`
  389. })
  390. })
  391. it.instance("ignores legacy tui keys in kirincode config", () =>
  392. Effect.gen(function* () {
  393. const test = yield* TestInstance
  394. yield* writeConfigEffect(test.directory, {
  395. $schema: "https://kirincode.ai/config.json",
  396. model: "test/model",
  397. theme: "legacy",
  398. tui: { scroll_speed: 4 },
  399. })
  400. const config = yield* Config.use.get()
  401. expect(config.model).toBe("test/model")
  402. expect((config as Record<string, unknown>).theme).toBeUndefined()
  403. expect((config as Record<string, unknown>).tui).toBeUndefined()
  404. }),
  405. )
  406. it.instance("loads JSONC config file", () =>
  407. Effect.gen(function* () {
  408. const test = yield* TestInstance
  409. yield* FSUtil.use.writeWithDirs(
  410. path.join(test.directory, "kirincode.jsonc"),
  411. `{
  412. // This is a comment
  413. "$schema": "https://kirincode.ai/config.json",
  414. "model": "test/model",
  415. "username": "testuser"
  416. }`,
  417. )
  418. const config = yield* Config.use.get()
  419. expect(config.model).toBe("test/model")
  420. expect(config.username).toBe("testuser")
  421. }),
  422. )
  423. it.instance("jsonc overrides json in the same directory", () =>
  424. Effect.gen(function* () {
  425. const test = yield* TestInstance
  426. yield* writeConfigEffect(
  427. test.directory,
  428. {
  429. $schema: "https://kirincode.ai/config.json",
  430. model: "base",
  431. username: "base",
  432. },
  433. "kirincode.jsonc",
  434. )
  435. yield* writeConfigEffect(test.directory, {
  436. $schema: "https://kirincode.ai/config.json",
  437. model: "override",
  438. })
  439. const config = yield* Config.use.get()
  440. expect(config.model).toBe("base")
  441. expect(config.username).toBe("base")
  442. }),
  443. )
  444. it.instance("handles environment variable substitution", () =>
  445. withProcessEnv(
  446. "TEST_VAR",
  447. "test-user",
  448. Effect.gen(function* () {
  449. const test = yield* TestInstance
  450. yield* writeConfigEffect(test.directory, {
  451. $schema: "https://kirincode.ai/config.json",
  452. username: "{env:TEST_VAR}",
  453. })
  454. const config = yield* Config.use.get()
  455. expect(config.username).toBe("test-user")
  456. }),
  457. ),
  458. )
  459. it.instance("preserves env variables when adding $schema to config", () =>
  460. withProcessEnv(
  461. "PRESERVE_VAR",
  462. "secret_value",
  463. Effect.gen(function* () {
  464. const test = yield* TestInstance
  465. // Config without $schema - should trigger auto-add
  466. yield* FSUtil.use.writeWithDirs(
  467. path.join(test.directory, "kirincode.json"),
  468. JSON.stringify({ username: "{env:PRESERVE_VAR}" }),
  469. )
  470. const config = yield* Config.use.get()
  471. expect(config.username).toBe("secret_value")
  472. // Read the file to verify the env variable was preserved
  473. const content = yield* FSUtil.use.readFileString(path.join(test.directory, "kirincode.json"))
  474. expect(content).toContain("{env:PRESERVE_VAR}")
  475. expect(content).not.toContain("secret_value")
  476. expect(content).toContain("$schema")
  477. }),
  478. ),
  479. )
  480. it.instance("handles file inclusion substitution", () =>
  481. Effect.gen(function* () {
  482. const test = yield* TestInstance
  483. yield* FSUtil.use.writeWithDirs(path.join(test.directory, "included.txt"), "test-user")
  484. yield* writeConfigEffect(test.directory, {
  485. $schema: "https://kirincode.ai/config.json",
  486. username: "{file:included.txt}",
  487. })
  488. const config = yield* Config.use.get()
  489. expect(config.username).toBe("test-user")
  490. }),
  491. )
  492. it.instance("handles file inclusion with replacement tokens", () =>
  493. Effect.gen(function* () {
  494. const test = yield* TestInstance
  495. yield* FSUtil.use.writeWithDirs(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`")
  496. yield* writeConfigEffect(test.directory, {
  497. $schema: "https://kirincode.ai/config.json",
  498. username: "{file:included.md}",
  499. })
  500. const config = yield* Config.use.get()
  501. expect(config.username).toBe("const out = await Bun.$`echo hi`")
  502. }),
  503. )
  504. const accountTokenIt = configIt({
  505. account: Layer.mock(Account.Service)({
  506. active: () =>
  507. Effect.succeed(
  508. Option.some({
  509. id: AccountID.make("account-1"),
  510. email: "user@example.com",
  511. url: "https://control.example.com",
  512. active_org_id: OrgID.make("org-1"),
  513. }),
  514. ),
  515. activeOrg: () =>
  516. Effect.succeed(
  517. Option.some({
  518. account: {
  519. id: AccountID.make("account-1"),
  520. email: "user@example.com",
  521. url: "https://control.example.com",
  522. active_org_id: OrgID.make("org-1"),
  523. },
  524. org: {
  525. id: OrgID.make("org-1"),
  526. name: "Example Org",
  527. },
  528. }),
  529. ),
  530. config: () =>
  531. Effect.succeed(
  532. Option.some({
  533. provider: { opencode: { options: { apiKey: "{env:KIRINCODE_CONSOLE_TOKEN}" } } },
  534. }),
  535. ),
  536. token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
  537. }),
  538. })
  539. accountTokenIt.instance("resolves env templates in account config with account token", () =>
  540. Effect.gen(function* () {
  541. const config = yield* Config.use.get()
  542. expect(config.provider?.["kirincode"]?.options?.apiKey).toBe("st_test_token")
  543. }),
  544. )
  545. it.instance("validates config schema and throws on invalid fields", () =>
  546. Effect.gen(function* () {
  547. const test = yield* TestInstance
  548. yield* writeConfigEffect(test.directory, {
  549. $schema: "https://kirincode.ai/config.json",
  550. invalid_field: "should cause error",
  551. })
  552. const exit = yield* Config.use.get().pipe(Effect.exit)
  553. expect(Exit.isFailure(exit)).toBe(true)
  554. }),
  555. )
  556. it.instance("throws error for invalid JSON", () =>
  557. Effect.gen(function* () {
  558. const test = yield* TestInstance
  559. yield* FSUtil.use.writeWithDirs(path.join(test.directory, "kirincode.json"), "{ invalid json }")
  560. const exit = yield* Config.use.get().pipe(Effect.exit)
  561. expect(Exit.isFailure(exit)).toBe(true)
  562. }),
  563. )
  564. it.instance("handles agent configuration", () =>
  565. Effect.gen(function* () {
  566. const test = yield* TestInstance
  567. yield* writeConfigEffect(test.directory, {
  568. $schema: "https://kirincode.ai/config.json",
  569. agent: {
  570. test_agent: {
  571. model: "test/model",
  572. temperature: 0.7,
  573. description: "test agent",
  574. },
  575. },
  576. })
  577. const config = yield* Config.use.get()
  578. expect(config.agent?.["test_agent"]).toEqual(
  579. expect.objectContaining({
  580. model: "test/model",
  581. temperature: 0.7,
  582. description: "test agent",
  583. }),
  584. )
  585. }),
  586. )
  587. it.instance("treats agent variant as model-scoped setting (not provider option)", () =>
  588. Effect.gen(function* () {
  589. const test = yield* TestInstance
  590. yield* writeConfigEffect(test.directory, {
  591. $schema: "https://kirincode.ai/config.json",
  592. agent: {
  593. test_agent: {
  594. model: "openai/gpt-5.2",
  595. variant: "xhigh",
  596. max_tokens: 123,
  597. },
  598. },
  599. })
  600. const config = yield* Config.use.get()
  601. const agent = config.agent?.["test_agent"]
  602. expect(agent?.variant).toBe("xhigh")
  603. expect(agent?.options).toMatchObject({
  604. max_tokens: 123,
  605. })
  606. expect(agent?.options).not.toHaveProperty("variant")
  607. }),
  608. )
  609. it.instance("handles command configuration", () =>
  610. Effect.gen(function* () {
  611. const test = yield* TestInstance
  612. yield* writeConfigEffect(test.directory, {
  613. $schema: "https://kirincode.ai/config.json",
  614. command: {
  615. test_command: {
  616. template: "test template",
  617. description: "test command",
  618. agent: "test_agent",
  619. },
  620. },
  621. })
  622. const config = yield* Config.use.get()
  623. expect(config.command?.["test_command"]).toEqual({
  624. template: "test template",
  625. description: "test command",
  626. agent: "test_agent",
  627. })
  628. }),
  629. )
  630. it.instance("migrates autoshare to share field", () =>
  631. Effect.gen(function* () {
  632. const test = yield* TestInstance
  633. yield* writeConfigEffect(test.directory, {
  634. $schema: "https://kirincode.ai/config.json",
  635. autoshare: true,
  636. })
  637. const config = yield* Config.use.get()
  638. expect(config.share).toBe("auto")
  639. expect(config.autoshare).toBe(true)
  640. }),
  641. )
  642. it.instance("migrates mode field to agent field", () =>
  643. Effect.gen(function* () {
  644. const test = yield* TestInstance
  645. yield* writeConfigEffect(test.directory, {
  646. $schema: "https://kirincode.ai/config.json",
  647. mode: {
  648. test_mode: {
  649. model: "test/model",
  650. temperature: 0.5,
  651. },
  652. },
  653. })
  654. const config = yield* Config.use.get()
  655. expect(config.agent?.["test_mode"]).toEqual({
  656. model: "test/model",
  657. temperature: 0.5,
  658. mode: "primary",
  659. options: {},
  660. permission: {},
  661. })
  662. }),
  663. )
  664. it.instance("accepts the deprecated reference field", () =>
  665. Effect.gen(function* () {
  666. const test = yield* TestInstance
  667. yield* writeConfigEffect(test.directory, {
  668. $schema: "https://kirincode.ai/config.json",
  669. reference: {
  670. local: { path: "../library" },
  671. sdk: { repository: "github.com/example/sdk", branch: "main" },
  672. shorthand: "github.com/example/docs",
  673. },
  674. })
  675. const config = yield* Config.use.get()
  676. expect(config.reference).toEqual({
  677. local: { path: "../library" },
  678. sdk: { repository: "github.com/example/sdk", branch: "main" },
  679. shorthand: "github.com/example/docs",
  680. })
  681. }),
  682. )
  683. it.instance("loads config from .kirincode directory", () =>
  684. Effect.gen(function* () {
  685. const test = yield* TestInstance
  686. yield* FSUtil.use.writeWithDirs(
  687. path.join(test.directory, ".kirincode", "agent", "test.md"),
  688. `---
  689. model: test/model
  690. ---
  691. Test agent prompt`,
  692. )
  693. const config = yield* Config.use.get()
  694. expect(config.agent?.["test"]).toEqual(
  695. expect.objectContaining({
  696. name: "test",
  697. model: "test/model",
  698. prompt: "Test agent prompt",
  699. }),
  700. )
  701. }),
  702. )
  703. it.instance("agent markdown permission config preserves user key order", () =>
  704. Effect.gen(function* () {
  705. const test = yield* TestInstance
  706. yield* FSUtil.use.writeWithDirs(
  707. path.join(test.directory, ".kirincode", "agent", "ordered.md"),
  708. `---
  709. permission:
  710. bash: allow
  711. "*": deny
  712. edit: ask
  713. ---
  714. Ordered permissions`,
  715. )
  716. const config = yield* Config.use.get()
  717. expect(Object.keys(config.agent?.ordered?.permission ?? {})).toEqual(["bash", "*", "edit"])
  718. }),
  719. )
  720. it.instance("loads agents from .kirincode/agents (plural)", () =>
  721. Effect.gen(function* () {
  722. const test = yield* TestInstance
  723. yield* FSUtil.use.writeWithDirs(
  724. path.join(test.directory, ".kirincode", "agents", "helper.md"),
  725. `---
  726. model: test/model
  727. mode: subagent
  728. ---
  729. Helper agent prompt`,
  730. )
  731. yield* FSUtil.use.writeWithDirs(
  732. path.join(test.directory, ".kirincode", "agents", "nested", "child.md"),
  733. `---
  734. model: test/model
  735. mode: subagent
  736. ---
  737. Nested agent prompt`,
  738. )
  739. const config = yield* Config.use.get()
  740. expect(config.agent?.["helper"]).toMatchObject({
  741. name: "helper",
  742. model: "test/model",
  743. mode: "subagent",
  744. prompt: "Helper agent prompt",
  745. })
  746. expect(config.agent?.["nested/child"]).toMatchObject({
  747. name: "nested/child",
  748. model: "test/model",
  749. mode: "subagent",
  750. prompt: "Nested agent prompt",
  751. })
  752. }),
  753. )
  754. it.instance("loads commands from .kirincode/command (singular)", () =>
  755. Effect.gen(function* () {
  756. const test = yield* TestInstance
  757. yield* FSUtil.use.writeWithDirs(
  758. path.join(test.directory, ".kirincode", "command", "hello.md"),
  759. `---
  760. description: Test command
  761. ---
  762. Hello from singular command`,
  763. )
  764. yield* FSUtil.use.writeWithDirs(
  765. path.join(test.directory, ".kirincode", "command", "nested", "child.md"),
  766. `---
  767. description: Nested command
  768. ---
  769. Nested command template`,
  770. )
  771. const config = yield* Config.use.get()
  772. expect(config.command?.["hello"]).toEqual({
  773. description: "Test command",
  774. template: "Hello from singular command",
  775. })
  776. expect(config.command?.["nested/child"]).toEqual({
  777. description: "Nested command",
  778. template: "Nested command template",
  779. })
  780. }),
  781. )
  782. it.instance("loads commands from .kirincode/commands (plural)", () =>
  783. Effect.gen(function* () {
  784. const test = yield* TestInstance
  785. yield* FSUtil.use.writeWithDirs(
  786. path.join(test.directory, ".kirincode", "commands", "hello.md"),
  787. `---
  788. description: Test command
  789. ---
  790. Hello from plural commands`,
  791. )
  792. yield* FSUtil.use.writeWithDirs(
  793. path.join(test.directory, ".kirincode", "commands", "nested", "child.md"),
  794. `---
  795. description: Nested command
  796. ---
  797. Nested command template`,
  798. )
  799. const config = yield* Config.use.get()
  800. expect(config.command?.["hello"]).toEqual({
  801. description: "Test command",
  802. template: "Hello from plural commands",
  803. })
  804. expect(config.command?.["nested/child"]).toEqual({
  805. description: "Nested command",
  806. template: "Nested command template",
  807. })
  808. }),
  809. )
  810. it.instance("updates config and writes to file", () =>
  811. Effect.gen(function* () {
  812. const test = yield* TestInstance
  813. yield* Config.Service.use((svc) =>
  814. svc.update(ConfigParse.schema(ConfigV1.Info, { model: "updated/model" }, "test:config")),
  815. )
  816. const writtenConfig = yield* FSUtil.use.readJson(path.join(test.directory, "config.json"))
  817. expect(writtenConfig).toMatchObject({ model: "updated/model" })
  818. }),
  819. )
  820. it.instance("gets config directories", () =>
  821. Effect.gen(function* () {
  822. const dirs = yield* Config.use.directories()
  823. expect(dirs.length).toBeGreaterThanOrEqual(1)
  824. }),
  825. )
  826. it.effect("does not try to install dependencies in read-only KIRINCODE_CONFIG_DIR", () =>
  827. Effect.gen(function* () {
  828. if (process.platform === "win32") return
  829. const dir = yield* tmpdirScoped()
  830. const readonly = path.join(dir, "readonly")
  831. yield* FSUtil.use.ensureDir(readonly)
  832. yield* FSUtil.use.chmod(readonly, 0o555)
  833. yield* Effect.addFinalizer(() => FSUtil.use.chmod(readonly, 0o755).pipe(Effect.ignore))
  834. yield* withProcessEnv("KIRINCODE_CONFIG_DIR", readonly, Config.use.get().pipe(provideInstanceEffect(dir)))
  835. }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
  836. )
  837. it.effect("ignores an inaccessible KIRINCODE_CONFIG_DIR", () =>
  838. Effect.gen(function* () {
  839. if (process.platform === "win32") return
  840. const dir = yield* tmpdirScoped()
  841. const configDir = path.join(dir, "inaccessible")
  842. yield* FSUtil.use.ensureDir(configDir)
  843. yield* FSUtil.use.chmod(configDir, 0o000)
  844. yield* Effect.addFinalizer(() => FSUtil.use.chmod(configDir, 0o755).pipe(Effect.ignore))
  845. yield* withProcessEnv("KIRINCODE_CONFIG_DIR", configDir, Config.use.get().pipe(provideInstanceEffect(dir)))
  846. }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
  847. )
  848. it.effect("creates a missing KIRINCODE_CONFIG_DIR", () =>
  849. Effect.gen(function* () {
  850. const dir = yield* tmpdirScoped()
  851. const configDir = path.join(dir, "configdir")
  852. yield* withProcessEnv("KIRINCODE_CONFIG_DIR", configDir, Config.use.get().pipe(provideInstanceEffect(dir)))
  853. expect(yield* FSUtil.use.readFileString(path.join(configDir, ".gitignore"))).toContain("node_modules")
  854. }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
  855. )
  856. it.effect("installs dependencies in writable KIRINCODE_CONFIG_DIR", () =>
  857. Effect.gen(function* () {
  858. const dir = yield* tmpdirScoped()
  859. const configDir = path.join(dir, "configdir")
  860. yield* FSUtil.use.ensureDir(configDir)
  861. yield* withProcessEnv(
  862. "KIRINCODE_CONFIG_DIR",
  863. configDir,
  864. Config.Service.use((svc) => svc.get().pipe(Effect.andThen(svc.waitForDependencies()))).pipe(
  865. provideInstanceEffect(dir),
  866. ),
  867. )
  868. expect(yield* FSUtil.use.readFileString(path.join(configDir, ".gitignore"))).toContain("package-lock.json")
  869. }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
  870. )
  871. // Note: deduplication and serialization of npm installs is now handled by the
  872. // core Npm.Service (via EffectFlock). Those behaviors are tested in the core
  873. // package's npm tests, not here.
  874. it.instance("resolves scoped npm plugins in config", () =>
  875. Effect.gen(function* () {
  876. const test = yield* TestInstance
  877. const pluginDir = path.join(test.directory, "node_modules", "@scope", "plugin")
  878. yield* FSUtil.use.writeWithDirs(
  879. path.join(test.directory, "package.json"),
  880. JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
  881. )
  882. yield* FSUtil.use.writeWithDirs(
  883. path.join(pluginDir, "package.json"),
  884. JSON.stringify(
  885. {
  886. name: "@scope/plugin",
  887. version: "1.0.0",
  888. type: "module",
  889. main: "./index.js",
  890. },
  891. null,
  892. 2,
  893. ),
  894. )
  895. yield* FSUtil.use.writeWithDirs(path.join(pluginDir, "index.js"), "export default {}\n")
  896. yield* writeConfigEffect(test.directory, { plugin: ["@scope/plugin"] })
  897. const config = yield* Config.use.get()
  898. expect(config.plugin ?? []).toContain("@scope/plugin")
  899. }),
  900. )
  901. it.effect("merges plugin arrays from global and local configs", () =>
  902. withConfigTree(
  903. {
  904. global: { plugin: ["global-plugin-1", "global-plugin-2"] },
  905. local: { plugin: ["local-plugin-1"] },
  906. },
  907. Effect.gen(function* () {
  908. const plugins = (yield* Config.use.get()).plugin ?? []
  909. expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
  910. expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true)
  911. expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
  912. expect(
  913. plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin")).length,
  914. ).toBeGreaterThanOrEqual(3)
  915. }),
  916. ),
  917. )
  918. it.effect("global config remains global when project config is disabled", () =>
  919. withConfigTree(
  920. {
  921. global: { model: "global/model", plugin: ["global-plugin"] },
  922. project: { model: "project/model" },
  923. local: { model: "local/model" },
  924. },
  925. withProcessEnv(
  926. "KIRINCODE_DISABLE_PROJECT_CONFIG",
  927. "true",
  928. Effect.gen(function* () {
  929. const config = yield* Config.use.get()
  930. expect(config.model).toBe("global/model")
  931. expect(config.plugin_origins?.find((item) => item.spec === "global-plugin")?.scope).toBe("global")
  932. }),
  933. ),
  934. ),
  935. )
  936. it.instance("does not error when only custom agent is a subagent", () =>
  937. Effect.gen(function* () {
  938. const test = yield* TestInstance
  939. yield* FSUtil.use.writeWithDirs(
  940. path.join(test.directory, ".kirincode", "agent", "helper.md"),
  941. `---
  942. model: test/model
  943. mode: subagent
  944. ---
  945. Helper subagent prompt`,
  946. )
  947. const config = yield* Config.use.get()
  948. expect(config.agent?.["helper"]).toMatchObject({
  949. name: "helper",
  950. model: "test/model",
  951. mode: "subagent",
  952. prompt: "Helper subagent prompt",
  953. })
  954. }),
  955. )
  956. it.effect("merges instructions arrays from global and local configs", () =>
  957. withConfigTree(
  958. {
  959. global: { instructions: ["global-instructions.md", "shared-rules.md"] },
  960. local: { instructions: ["local-instructions.md"] },
  961. },
  962. Effect.gen(function* () {
  963. expect((yield* Config.use.get()).instructions).toEqual([
  964. "global-instructions.md",
  965. "shared-rules.md",
  966. "local-instructions.md",
  967. ])
  968. }),
  969. ),
  970. )
  971. it.effect("deduplicates duplicate instructions from global and local configs", () =>
  972. withConfigTree(
  973. {
  974. global: { instructions: ["duplicate.md", "global-only.md"] },
  975. local: { instructions: ["duplicate.md", "local-only.md"] },
  976. },
  977. Effect.gen(function* () {
  978. expect((yield* Config.use.get()).instructions).toEqual(["duplicate.md", "global-only.md", "local-only.md"])
  979. }),
  980. ),
  981. )
  982. it.effect("deduplicates duplicate plugins from global and local configs", () =>
  983. withConfigTree(
  984. {
  985. global: { plugin: ["duplicate-plugin", "global-plugin-1"] },
  986. local: { plugin: ["duplicate-plugin", "local-plugin-1"] },
  987. },
  988. Effect.gen(function* () {
  989. const plugins = (yield* Config.use.get()).plugin ?? []
  990. expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
  991. expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
  992. expect(plugins.filter((p) => p.includes("duplicate-plugin")).length).toBe(1)
  993. expect(
  994. plugins.filter(
  995. (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
  996. ).length,
  997. ).toBe(3)
  998. }),
  999. ),
  1000. )
  1001. it.effect("keeps plugin origins aligned with merged plugin list", () =>
  1002. withConfigTree(
  1003. {
  1004. global: { plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"] },
  1005. local: { plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"] },
  1006. },
  1007. Effect.gen(function* () {
  1008. const config = yield* Config.use.get()
  1009. const plugins = config.plugin ?? []
  1010. const origins = config.plugin_origins ?? []
  1011. const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item))
  1012. expect(names).toContain("shared-plugin@2.0.0")
  1013. expect(names).not.toContain("shared-plugin@1.0.0")
  1014. expect(names).toContain("global-only@1.0.0")
  1015. expect(names).toContain("local-only@1.0.0")
  1016. expect(origins.map((item) => item.spec)).toEqual(plugins)
  1017. expect(origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")?.scope).toBe(
  1018. "local",
  1019. )
  1020. }),
  1021. ),
  1022. )
  1023. // Legacy tools migration tests
  1024. it.instance("migrates legacy tools config to permissions - allow", () =>
  1025. Effect.gen(function* () {
  1026. const test = yield* TestInstance
  1027. yield* writeConfigEffect(test.directory, {
  1028. $schema: "https://kirincode.ai/config.json",
  1029. agent: { test: { tools: { bash: true, read: true } } },
  1030. })
  1031. const config = yield* Config.use.get()
  1032. expect(config.agent?.["test"]?.permission).toEqual({
  1033. bash: "allow",
  1034. read: "allow",
  1035. })
  1036. }),
  1037. )
  1038. it.instance("migrates legacy tools config to permissions - deny", () =>
  1039. Effect.gen(function* () {
  1040. const test = yield* TestInstance
  1041. yield* writeConfigEffect(test.directory, {
  1042. $schema: "https://kirincode.ai/config.json",
  1043. agent: { test: { tools: { bash: false, webfetch: false } } },
  1044. })
  1045. const config = yield* Config.use.get()
  1046. expect(config.agent?.["test"]?.permission).toEqual({
  1047. bash: "deny",
  1048. webfetch: "deny",
  1049. })
  1050. }),
  1051. )
  1052. it.instance("migrates legacy write tool to edit permission", () =>
  1053. Effect.gen(function* () {
  1054. const test = yield* TestInstance
  1055. yield* writeConfigEffect(test.directory, {
  1056. $schema: "https://kirincode.ai/config.json",
  1057. agent: { test: { tools: { write: true } } },
  1058. })
  1059. const config = yield* Config.use.get()
  1060. expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow" })
  1061. }),
  1062. )
  1063. // Managed settings tests
  1064. // Note: preload.ts sets KIRINCODE_TEST_MANAGED_CONFIG which Global.Path.managedConfig uses
  1065. it.instance(
  1066. "managed settings override user settings",
  1067. Effect.gen(function* () {
  1068. yield* writeManagedSettingsEffect({
  1069. $schema: "https://kirincode.ai/config.json",
  1070. model: "managed/model",
  1071. share: "disabled",
  1072. })
  1073. const config = yield* Config.use.get()
  1074. expect(config.model).toBe("managed/model")
  1075. expect(config.share).toBe("disabled")
  1076. expect(config.username).toBe("testuser")
  1077. }),
  1078. { config: { model: "user/model", share: "auto", username: "testuser" } },
  1079. )
  1080. it.instance(
  1081. "managed settings override project settings",
  1082. Effect.gen(function* () {
  1083. yield* writeManagedSettingsEffect({
  1084. $schema: "https://kirincode.ai/config.json",
  1085. autoupdate: false,
  1086. disabled_providers: ["openai"],
  1087. })
  1088. const config = yield* Config.use.get()
  1089. expect(config.autoupdate).toBe(false)
  1090. expect(config.disabled_providers).toEqual(["openai"])
  1091. }),
  1092. { config: { autoupdate: true, disabled_providers: [] } },
  1093. )
  1094. it.instance("managed jsonc settings override managed json settings", () =>
  1095. Effect.gen(function* () {
  1096. yield* writeManagedSettingsEffect({ model: "managed/json" })
  1097. yield* writeManagedSettingsEffect({ model: "managed/jsonc" }, "kirincode.jsonc")
  1098. const config = yield* Config.use.get()
  1099. expect(config.model).toBe("managed/jsonc")
  1100. }),
  1101. )
  1102. it.instance(
  1103. "missing managed settings file is not an error",
  1104. Effect.gen(function* () {
  1105. const config = yield* Config.use.get()
  1106. expect(config.model).toBe("user/model")
  1107. }),
  1108. { config: { model: "user/model" } },
  1109. )
  1110. it.instance("migrates legacy edit tool to edit permission", () =>
  1111. Effect.gen(function* () {
  1112. const test = yield* TestInstance
  1113. yield* writeConfigEffect(test.directory, {
  1114. $schema: "https://kirincode.ai/config.json",
  1115. agent: { test: { tools: { edit: false } } },
  1116. })
  1117. const config = yield* Config.use.get()
  1118. expect(config.agent?.["test"]?.permission).toEqual({ edit: "deny" })
  1119. }),
  1120. )
  1121. it.instance("migrates legacy patch tool to edit permission", () =>
  1122. Effect.gen(function* () {
  1123. const test = yield* TestInstance
  1124. yield* writeConfigEffect(test.directory, {
  1125. $schema: "https://kirincode.ai/config.json",
  1126. agent: { test: { tools: { patch: true } } },
  1127. })
  1128. const config = yield* Config.use.get()
  1129. expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow" })
  1130. }),
  1131. )
  1132. it.instance("migrates mixed legacy tools config", () =>
  1133. Effect.gen(function* () {
  1134. const test = yield* TestInstance
  1135. yield* writeConfigEffect(test.directory, {
  1136. $schema: "https://kirincode.ai/config.json",
  1137. agent: { test: { tools: { bash: true, write: true, read: false, webfetch: true } } },
  1138. })
  1139. const config = yield* Config.use.get()
  1140. expect(config.agent?.["test"]?.permission).toEqual({
  1141. bash: "allow",
  1142. edit: "allow",
  1143. read: "deny",
  1144. webfetch: "allow",
  1145. })
  1146. }),
  1147. )
  1148. it.instance("merges legacy tools with existing permission config", () =>
  1149. Effect.gen(function* () {
  1150. const test = yield* TestInstance
  1151. yield* writeConfigEffect(test.directory, {
  1152. $schema: "https://kirincode.ai/config.json",
  1153. agent: { test: { permission: { glob: "allow" }, tools: { bash: true } } },
  1154. })
  1155. const config = yield* Config.use.get()
  1156. expect(config.agent?.["test"]?.permission).toEqual({
  1157. glob: "allow",
  1158. bash: "allow",
  1159. })
  1160. }),
  1161. )
  1162. it.instance("permission config preserves user key order", () =>
  1163. // Permission precedence follows the order users write in config, so parsing
  1164. // must not canonicalise known keys ahead of wildcard or custom keys.
  1165. Effect.gen(function* () {
  1166. const test = yield* TestInstance
  1167. yield* writeConfigEffect(test.directory, {
  1168. $schema: "https://kirincode.ai/config.json",
  1169. permission: {
  1170. "*": "deny",
  1171. edit: "ask",
  1172. write: "ask",
  1173. external_directory: "ask",
  1174. read: "allow",
  1175. todowrite: "allow",
  1176. "thoughts_*": "allow",
  1177. "reasoning_model_*": "allow",
  1178. "tools_*": "allow",
  1179. "pr_comments_*": "allow",
  1180. },
  1181. })
  1182. const config = yield* Config.use.get()
  1183. expect(Object.keys(config.permission!)).toEqual([
  1184. "*",
  1185. "edit",
  1186. "write",
  1187. "external_directory",
  1188. "read",
  1189. "todowrite",
  1190. "thoughts_*",
  1191. "reasoning_model_*",
  1192. "tools_*",
  1193. "pr_comments_*",
  1194. ])
  1195. }),
  1196. )
  1197. test("config parser preserves permission order while rejecting unknown top-level keys", () => {
  1198. const config = ConfigParse.schema(
  1199. ConfigV1.Info,
  1200. {
  1201. permission: {
  1202. bash: "allow",
  1203. "*": "deny",
  1204. edit: "ask",
  1205. },
  1206. },
  1207. "test",
  1208. )
  1209. expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
  1210. try {
  1211. ConfigParse.schema(ConfigV1.Info, { invalid_field: true }, "test")
  1212. throw new Error("expected config parse to fail")
  1213. } catch (err) {
  1214. const error = err as { data?: { issues?: Array<{ code?: string; keys?: string[]; path?: string[] }> } }
  1215. expect(error.data?.issues?.[0]).toMatchObject({ code: "unrecognized_keys", keys: ["invalid_field"], path: [] })
  1216. }
  1217. })
  1218. // MCP config merging tests
  1219. it.instance("project config can override MCP server enabled status", () =>
  1220. Effect.gen(function* () {
  1221. const test = yield* TestInstance
  1222. // Simulates a base config (like from remote .well-known) with disabled MCP.
  1223. yield* writeConfigEffect(test.directory, {
  1224. $schema: "https://kirincode.ai/config.json",
  1225. mcp: {
  1226. jira: {
  1227. type: "remote",
  1228. url: "https://jira.example.com/mcp",
  1229. enabled: false,
  1230. },
  1231. wiki: {
  1232. type: "remote",
  1233. url: "https://wiki.example.com/mcp",
  1234. enabled: false,
  1235. },
  1236. },
  1237. })
  1238. // Project config enables just jira.
  1239. yield* writeConfigEffect(
  1240. test.directory,
  1241. {
  1242. $schema: "https://kirincode.ai/config.json",
  1243. mcp: {
  1244. jira: {
  1245. type: "remote",
  1246. url: "https://jira.example.com/mcp",
  1247. enabled: true,
  1248. },
  1249. },
  1250. },
  1251. "kirincode.jsonc",
  1252. )
  1253. const config = yield* Config.use.get()
  1254. expect(config.mcp?.jira).toEqual({
  1255. type: "remote",
  1256. url: "https://jira.example.com/mcp",
  1257. enabled: true,
  1258. })
  1259. expect(config.mcp?.wiki).toEqual({
  1260. type: "remote",
  1261. url: "https://wiki.example.com/mcp",
  1262. enabled: false,
  1263. })
  1264. }),
  1265. )
  1266. it.instance("MCP config deep merges preserving base config properties", () =>
  1267. Effect.gen(function* () {
  1268. const test = yield* TestInstance
  1269. yield* writeConfigEffect(test.directory, {
  1270. $schema: "https://kirincode.ai/config.json",
  1271. mcp: {
  1272. myserver: {
  1273. type: "remote",
  1274. url: "https://myserver.example.com/mcp",
  1275. enabled: false,
  1276. headers: {
  1277. "X-Custom-Header": "value",
  1278. },
  1279. },
  1280. },
  1281. })
  1282. yield* writeConfigEffect(
  1283. test.directory,
  1284. {
  1285. $schema: "https://kirincode.ai/config.json",
  1286. mcp: {
  1287. myserver: {
  1288. type: "remote",
  1289. url: "https://myserver.example.com/mcp",
  1290. enabled: true,
  1291. },
  1292. },
  1293. },
  1294. "kirincode.jsonc",
  1295. )
  1296. const config = yield* Config.use.get()
  1297. expect(config.mcp?.myserver).toEqual({
  1298. type: "remote",
  1299. url: "https://myserver.example.com/mcp",
  1300. enabled: true,
  1301. headers: {
  1302. "X-Custom-Header": "value",
  1303. },
  1304. })
  1305. }),
  1306. )
  1307. it.instance("local .kirincode config can override MCP from project config", () =>
  1308. Effect.gen(function* () {
  1309. const test = yield* TestInstance
  1310. yield* writeConfigEffect(test.directory, {
  1311. $schema: "https://kirincode.ai/config.json",
  1312. mcp: {
  1313. docs: {
  1314. type: "remote",
  1315. url: "https://docs.example.com/mcp",
  1316. enabled: false,
  1317. },
  1318. },
  1319. })
  1320. yield* FSUtil.use.ensureDir(path.join(test.directory, ".kirincode"))
  1321. yield* writeConfigEffect(
  1322. path.join(test.directory, ".kirincode"),
  1323. {
  1324. $schema: "https://kirincode.ai/config.json",
  1325. mcp: {
  1326. docs: {
  1327. type: "remote",
  1328. url: "https://docs.example.com/mcp",
  1329. enabled: true,
  1330. },
  1331. },
  1332. },
  1333. "kirincode.json",
  1334. )
  1335. const config = yield* Config.use.get()
  1336. expect(config.mcp?.docs?.enabled).toBe(true)
  1337. }),
  1338. )
  1339. const remoteProjectOverride = wellKnown({
  1340. config: {
  1341. mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
  1342. },
  1343. })
  1344. remoteProjectOverride.it.instance(
  1345. "project config overrides remote well-known config",
  1346. () =>
  1347. Effect.gen(function* () {
  1348. const config = yield* Config.use.get()
  1349. expect(remoteProjectOverride.seen.wellKnown).toBe("https://example.com/.well-known/opencode")
  1350. expect(config.mcp?.jira?.enabled).toBe(true)
  1351. }),
  1352. {
  1353. git: true,
  1354. config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
  1355. },
  1356. )
  1357. const trailingSlashWellKnown = wellKnown({
  1358. authUrl: "https://example.com/",
  1359. config: {
  1360. mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } },
  1361. },
  1362. })
  1363. trailingSlashWellKnown.it.instance("wellknown URL with trailing slash is normalized", () =>
  1364. Effect.gen(function* () {
  1365. yield* Config.use.get()
  1366. expect(trailingSlashWellKnown.seen.wellKnown).toBe("https://example.com/.well-known/opencode")
  1367. }),
  1368. )
  1369. test("remote well-known config can use FetchHttpClient layer", async () => {
  1370. let fetchedUrl: string | undefined
  1371. const server = Bun.serve({
  1372. port: 0,
  1373. fetch: (request) => {
  1374. fetchedUrl = request.url
  1375. return new Response(
  1376. JSON.stringify({
  1377. config: {
  1378. mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } },
  1379. },
  1380. }),
  1381. { status: 200, headers: { "content-type": "application/json" } },
  1382. )
  1383. },
  1384. })
  1385. try {
  1386. await provideTmpdirInstance(
  1387. () =>
  1388. Config.Service.use((svc) =>
  1389. Effect.gen(function* () {
  1390. const config = yield* svc.get()
  1391. expect(fetchedUrl).toBe(`${server.url.origin}/.well-known/opencode`)
  1392. expect(config.mcp?.jira?.enabled).toBe(true)
  1393. }),
  1394. ),
  1395. { git: true },
  1396. ).pipe(
  1397. Effect.scoped,
  1398. Effect.provide(
  1399. Layer.mergeAll(
  1400. LayerNode.compile(LayerNode.group([Config.node, FSUtil.node, Env.node, CrossSpawnSpawner.node]), [
  1401. [Auth.node, wellKnownAuth(server.url.origin)],
  1402. [Account.node, AccountTest.empty],
  1403. [Npm.node, NpmTest.noop],
  1404. [httpClient, FetchHttpClient.layer],
  1405. ]),
  1406. testInstanceStoreLayer,
  1407. ),
  1408. ),
  1409. Effect.runPromise,
  1410. )
  1411. } finally {
  1412. await server.stop(true)
  1413. }
  1414. })
  1415. const templatedHeaderWellKnown = wellKnown({
  1416. remoteConfig: {
  1417. url: "https://config.example.com/kirincode.json",
  1418. headers: { Authorization: "Bearer {env:TEST_TOKEN}" },
  1419. },
  1420. remote: {
  1421. mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
  1422. },
  1423. })
  1424. templatedHeaderWellKnown.it.instance("wellknown remote_config supports templated env vars in headers", () =>
  1425. Effect.gen(function* () {
  1426. const config = yield* Config.use.get()
  1427. expect(templatedHeaderWellKnown.seen.wellKnown).toBe("https://example.com/.well-known/opencode")
  1428. expect(templatedHeaderWellKnown.seen.remote).toBe("https://config.example.com/kirincode.json")
  1429. expect(templatedHeaderWellKnown.seen.authorization).toBe("Bearer test-token")
  1430. expect(config.mcp?.confluence?.enabled).toBe(true)
  1431. }),
  1432. )
  1433. const remotePrecedenceWellKnown = wellKnown({
  1434. config: {
  1435. mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: false } },
  1436. },
  1437. remoteConfig: { url: "https://config.example.com/{env:TEST_TOKEN}/kirincode.json" },
  1438. remote: {
  1439. config: { mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } } },
  1440. },
  1441. })
  1442. remotePrecedenceWellKnown.it.instance(
  1443. "wellknown remote_config url tokens and nested config override embedded config",
  1444. () =>
  1445. Effect.gen(function* () {
  1446. const config = yield* Config.use.get()
  1447. expect(remotePrecedenceWellKnown.seen.remote).toBe("https://config.example.com/test-token/kirincode.json")
  1448. expect(config.mcp?.confluence?.enabled).toBe(true)
  1449. }),
  1450. )
  1451. const envIsolationWellKnown = wellKnown({
  1452. remoteConfig: {
  1453. url: "https://config.example.com/kirincode.json",
  1454. headers: { Authorization: "Bearer {env:TEST_TOKEN}" },
  1455. },
  1456. remote: {
  1457. mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
  1458. },
  1459. })
  1460. envIsolationWellKnown.it.instance(
  1461. "wellknown token env substitution does not mutate process env",
  1462. () =>
  1463. Effect.gen(function* () {
  1464. process.env.TEST_TOKEN = "preexisting-token"
  1465. const config = yield* Config.use.get()
  1466. expect(envIsolationWellKnown.seen.authorization).toBe("Bearer test-token")
  1467. expect(config.username).toBe("test-token")
  1468. expect(process.env.TEST_TOKEN).toBe("preexisting-token")
  1469. }),
  1470. { git: true, config: { username: "{env:TEST_TOKEN}" } },
  1471. )
  1472. const nullConfigWellKnown = wellKnown({
  1473. wellKnown: {
  1474. config: null,
  1475. remote_config: { url: "https://config.example.com/kirincode.json" },
  1476. },
  1477. remote: {
  1478. mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
  1479. },
  1480. })
  1481. nullConfigWellKnown.it.instance("wellknown config null is treated as absent", () =>
  1482. Effect.gen(function* () {
  1483. const config = yield* Config.use.get()
  1484. expect(nullConfigWellKnown.seen.remote).toBe("https://config.example.com/kirincode.json")
  1485. expect(config.mcp?.confluence?.enabled).toBe(true)
  1486. }),
  1487. )
  1488. const invalidRemoteWellKnown = wellKnown({
  1489. remoteConfig: { url: "https://config.example.com/kirincode.json" },
  1490. remote: "not an object",
  1491. })
  1492. invalidRemoteWellKnown.it.instance("wellknown remote_config rejects non-object config responses", () =>
  1493. Effect.gen(function* () {
  1494. const exit = yield* Config.use.get().pipe(Effect.exit)
  1495. expect(invalidRemoteWellKnown.seen.remote).toBe("https://config.example.com/kirincode.json")
  1496. expect(Exit.isFailure(exit)).toBe(true)
  1497. }),
  1498. )
  1499. const loginPageWellKnown = wellKnown({
  1500. remoteConfig: { url: "https://config.example.com/kirincode.json" },
  1501. remoteHtml: "<!DOCTYPE html><html><head><title>Sign in</title></head><body>Login required</body></html>",
  1502. })
  1503. loginPageWellKnown.it.instance(
  1504. "wellknown remote_config surfaces an actionable auth error when the gateway returns an HTML login page",
  1505. () =>
  1506. Effect.gen(function* () {
  1507. const exit = yield* Config.use.get().pipe(Effect.exit)
  1508. expect(loginPageWellKnown.seen.remote).toBe("https://config.example.com/kirincode.json")
  1509. expect(Exit.isFailure(exit)).toBe(true)
  1510. const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined
  1511. expect(NamedError.hasName(error, "ConfigRemoteAuthError")).toBe(true)
  1512. expect((error as { data?: { url?: string } }).data?.url).toBe("https://example.com")
  1513. }),
  1514. )
  1515. describe("resolvePluginSpec", () => {
  1516. test("keeps package specs unchanged", async () => {
  1517. await using tmp = await tmpdir()
  1518. const file = path.join(tmp.path, "kirincode.json")
  1519. expect(await ConfigPlugin.resolvePluginSpec("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3")
  1520. expect(await ConfigPlugin.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
  1521. })
  1522. test("resolves windows-style relative plugin directory specs", async () => {
  1523. if (process.platform !== "win32") return
  1524. await using tmp = await tmpdir({
  1525. init: async (dir) => {
  1526. const plugin = path.join(dir, "plugin")
  1527. await fs.mkdir(plugin, { recursive: true })
  1528. await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
  1529. },
  1530. })
  1531. const file = path.join(tmp.path, "kirincode.json")
  1532. const hit = await ConfigPlugin.resolvePluginSpec(".\\plugin", file)
  1533. expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
  1534. })
  1535. test("resolves relative file plugin paths to file urls", async () => {
  1536. await using tmp = await tmpdir({
  1537. init: async (dir) => {
  1538. await Filesystem.write(path.join(dir, "plugin.ts"), "export default {}")
  1539. },
  1540. })
  1541. const file = path.join(tmp.path, "kirincode.json")
  1542. const hit = await ConfigPlugin.resolvePluginSpec("./plugin.ts", file)
  1543. expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href)
  1544. })
  1545. test("resolves plugin directory paths to directory urls", async () => {
  1546. await using tmp = await tmpdir({
  1547. init: async (dir) => {
  1548. const plugin = path.join(dir, "plugin")
  1549. await fs.mkdir(plugin, { recursive: true })
  1550. await Filesystem.writeJson(path.join(plugin, "package.json"), {
  1551. name: "demo-plugin",
  1552. type: "module",
  1553. main: "./index.ts",
  1554. })
  1555. await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
  1556. },
  1557. })
  1558. const file = path.join(tmp.path, "kirincode.json")
  1559. const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file)
  1560. expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href)
  1561. })
  1562. test("resolves plugin directories without package.json to index.ts", async () => {
  1563. await using tmp = await tmpdir({
  1564. init: async (dir) => {
  1565. const plugin = path.join(dir, "plugin")
  1566. await fs.mkdir(plugin, { recursive: true })
  1567. await Filesystem.write(path.join(plugin, "index.ts"), "export default {}")
  1568. },
  1569. })
  1570. const file = path.join(tmp.path, "kirincode.json")
  1571. const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file)
  1572. expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
  1573. })
  1574. })
  1575. describe("deduplicatePluginOrigins", () => {
  1576. const dedupe = (plugins: ConfigPluginV1.Spec[]) =>
  1577. ConfigPlugin.deduplicatePluginOrigins(
  1578. plugins.map((spec) => ({
  1579. spec,
  1580. source: "",
  1581. scope: "global" as const,
  1582. })),
  1583. ).map((item) => item.spec)
  1584. test("removes duplicates keeping higher priority (later entries)", () => {
  1585. const plugins = ["global-plugin@1.0.0", "shared-plugin@1.0.0", "local-plugin@2.0.0", "shared-plugin@2.0.0"]
  1586. const result = dedupe(plugins)
  1587. expect(result).toContain("global-plugin@1.0.0")
  1588. expect(result).toContain("local-plugin@2.0.0")
  1589. expect(result).toContain("shared-plugin@2.0.0")
  1590. expect(result).not.toContain("shared-plugin@1.0.0")
  1591. expect(result.length).toBe(3)
  1592. })
  1593. test("keeps path plugins separate from package plugins", () => {
  1594. const plugins = ["oh-my-opencode@2.4.3", "file:///project/.kirincode/plugin/oh-my-opencode.js"]
  1595. const result = dedupe(plugins)
  1596. expect(result).toEqual(plugins)
  1597. })
  1598. test("deduplicates direct path plugins by exact spec", () => {
  1599. const plugins = ["file:///project/.kirincode/plugin/demo.ts", "file:///project/.kirincode/plugin/demo.ts"]
  1600. const result = dedupe(plugins)
  1601. expect(result).toEqual(["file:///project/.kirincode/plugin/demo.ts"])
  1602. })
  1603. test("preserves order of remaining plugins", () => {
  1604. const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]
  1605. const result = dedupe(plugins)
  1606. expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
  1607. })
  1608. it.effect("loads auto-discovered local plugins as file urls", () =>
  1609. withConfigTree(
  1610. { global: { plugin: ["my-plugin@1.0.0"] } },
  1611. Effect.gen(function* () {
  1612. const test = yield* TestInstance
  1613. yield* FSUtil.use.writeWithDirs(
  1614. path.join(test.directory, ".kirincode", "plugin", "my-plugin.js"),
  1615. "export default {}",
  1616. )
  1617. const plugins = (yield* Config.use.get()).plugin ?? []
  1618. expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
  1619. expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p).startsWith("file://"))).toBe(true)
  1620. }),
  1621. ),
  1622. )
  1623. })
  1624. describe("KIRINCODE_DISABLE_PROJECT_CONFIG", () => {
  1625. it.instance(
  1626. "skips project config files when flag is set",
  1627. () =>
  1628. withProcessEnv(
  1629. "KIRINCODE_DISABLE_PROJECT_CONFIG",
  1630. "true",
  1631. Effect.gen(function* () {
  1632. const config = yield* Config.use.get()
  1633. expect(config.model).not.toBe("project/model")
  1634. expect(config.username).not.toBe("project-user")
  1635. }),
  1636. ),
  1637. { config: { model: "project/model", username: "project-user" } },
  1638. )
  1639. it.instance("skips project .kirincode/ directories when flag is set", () =>
  1640. withProcessEnv(
  1641. "KIRINCODE_DISABLE_PROJECT_CONFIG",
  1642. "true",
  1643. Effect.gen(function* () {
  1644. const test = yield* TestInstance
  1645. yield* FSUtil.use.writeWithDirs(
  1646. path.join(test.directory, ".kirincode", "command", "test-cmd.md"),
  1647. "# Test Command\nThis is a test command.",
  1648. )
  1649. const directories = yield* Config.use.directories()
  1650. expect(directories.some((d) => d.startsWith(test.directory))).toBe(false)
  1651. }),
  1652. ),
  1653. )
  1654. it.instance("still loads global config when flag is set", () =>
  1655. withProcessEnv(
  1656. "KIRINCODE_DISABLE_PROJECT_CONFIG",
  1657. "true",
  1658. Effect.gen(function* () {
  1659. const config = yield* Config.use.get()
  1660. expect(config).toBeDefined()
  1661. expect(config.username).toBeDefined()
  1662. }),
  1663. ),
  1664. )
  1665. it.instance(
  1666. "skips relative instructions with warning when flag is set but no config dir",
  1667. () =>
  1668. withProcessEnvs(
  1669. { KIRINCODE_CONFIG_DIR: undefined, KIRINCODE_DISABLE_PROJECT_CONFIG: "true" },
  1670. Effect.gen(function* () {
  1671. const test = yield* TestInstance
  1672. yield* FSUtil.use.writeWithDirs(path.join(test.directory, "CUSTOM.md"), "# Custom Instructions")
  1673. // The relative instruction should be skipped without error
  1674. const config = yield* Config.use.get()
  1675. expect(config).toBeDefined()
  1676. }),
  1677. ),
  1678. { config: { instructions: ["./CUSTOM.md"] } },
  1679. )
  1680. it.instance(
  1681. "KIRINCODE_CONFIG_DIR still works when flag is set",
  1682. () =>
  1683. Effect.gen(function* () {
  1684. const configDir = yield* tmpdirScoped({ config: { model: "configdir/model" } })
  1685. yield* withProcessEnvs(
  1686. { KIRINCODE_DISABLE_PROJECT_CONFIG: "true", KIRINCODE_CONFIG_DIR: configDir },
  1687. Effect.gen(function* () {
  1688. const config = yield* Config.use.get()
  1689. expect(config.model).toBe("configdir/model")
  1690. }),
  1691. )
  1692. }),
  1693. { config: { model: "project/model" } },
  1694. )
  1695. })
  1696. // Regression for #28206: malformed KIRINCODE_PERMISSION JSON used to crash
  1697. // the app on startup with an unhandled SyntaxError. Loading the config with
  1698. // an invalid JSON value in this env var should not throw.
  1699. describe("KIRINCODE_PERMISSION env var", () => {
  1700. it.instance("does not crash when KIRINCODE_PERMISSION contains invalid JSON", () =>
  1701. withProcessEnv(
  1702. "KIRINCODE_PERMISSION",
  1703. "{invalid",
  1704. Effect.gen(function* () {
  1705. const config = yield* Config.use.get()
  1706. // Regression: load() used to throw before returning anything.
  1707. expect(config).toBeDefined()
  1708. }),
  1709. ),
  1710. )
  1711. })
  1712. describe("KIRINCODE_CONFIG_CONTENT token substitution", () => {
  1713. it.instance("substitutes {env:} tokens in KIRINCODE_CONFIG_CONTENT", () =>
  1714. withProcessEnv(
  1715. "TEST_CONFIG_VAR",
  1716. "test_api_key_12345",
  1717. withProcessEnv(
  1718. "KIRINCODE_CONFIG_CONTENT",
  1719. JSON.stringify({
  1720. $schema: "https://kirincode.ai/config.json",
  1721. username: "{env:TEST_CONFIG_VAR}",
  1722. }),
  1723. Effect.gen(function* () {
  1724. const config = yield* Config.use.get()
  1725. expect(config.username).toBe("test_api_key_12345")
  1726. }),
  1727. ),
  1728. ),
  1729. )
  1730. it.instance("substitutes {file:} tokens in KIRINCODE_CONFIG_CONTENT", () =>
  1731. Effect.gen(function* () {
  1732. const test = yield* TestInstance
  1733. yield* FSUtil.use.writeWithDirs(path.join(test.directory, "api_key.txt"), "secret_key_from_file")
  1734. yield* withProcessEnv(
  1735. "KIRINCODE_CONFIG_CONTENT",
  1736. JSON.stringify({
  1737. $schema: "https://kirincode.ai/config.json",
  1738. username: "{file:./api_key.txt}",
  1739. }),
  1740. Effect.gen(function* () {
  1741. const config = yield* Config.use.get()
  1742. expect(config.username).toBe("secret_key_from_file")
  1743. }),
  1744. )
  1745. }),
  1746. )
  1747. })
  1748. // parseManagedPlist unit tests — pure function, no OS interaction
  1749. test("parseManagedPlist strips MDM metadata keys", async () => {
  1750. const config = ConfigParse.schema(
  1751. ConfigV1.Info,
  1752. ConfigParse.jsonc(
  1753. await ConfigManaged.parseManagedPlist(
  1754. JSON.stringify({
  1755. PayloadDisplayName: "KirinCode Managed",
  1756. PayloadIdentifier: "ai.kirincode.managed.test",
  1757. PayloadType: "ai.kirincode.managed",
  1758. PayloadUUID: "AAAA-BBBB-CCCC",
  1759. PayloadVersion: 1,
  1760. _manualProfile: true,
  1761. share: "disabled",
  1762. model: "mdm/model",
  1763. }),
  1764. ),
  1765. "test:mobileconfig",
  1766. ),
  1767. "test:mobileconfig",
  1768. )
  1769. expect(config.share).toBe("disabled")
  1770. expect(config.model).toBe("mdm/model")
  1771. // MDM keys must not leak into the parsed config
  1772. expect((config as any).PayloadUUID).toBeUndefined()
  1773. expect((config as any).PayloadType).toBeUndefined()
  1774. expect((config as any)._manualProfile).toBeUndefined()
  1775. })
  1776. test("parseManagedPlist parses server settings", async () => {
  1777. const config = ConfigParse.schema(
  1778. ConfigV1.Info,
  1779. ConfigParse.jsonc(
  1780. await ConfigManaged.parseManagedPlist(
  1781. JSON.stringify({
  1782. $schema: "https://kirincode.ai/config.json",
  1783. server: { hostname: "127.0.0.1", mdns: false },
  1784. autoupdate: true,
  1785. }),
  1786. ),
  1787. "test:mobileconfig",
  1788. ),
  1789. "test:mobileconfig",
  1790. )
  1791. expect(config.server?.hostname).toBe("127.0.0.1")
  1792. expect(config.server?.mdns).toBe(false)
  1793. expect(config.autoupdate).toBe(true)
  1794. })
  1795. test("parseManagedPlist parses permission rules", async () => {
  1796. const config = ConfigParse.schema(
  1797. ConfigV1.Info,
  1798. ConfigParse.jsonc(
  1799. await ConfigManaged.parseManagedPlist(
  1800. JSON.stringify({
  1801. $schema: "https://kirincode.ai/config.json",
  1802. permission: {
  1803. "*": "ask",
  1804. bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
  1805. grep: "allow",
  1806. glob: "allow",
  1807. webfetch: "ask",
  1808. "~/.ssh/*": "deny",
  1809. },
  1810. }),
  1811. ),
  1812. "test:mobileconfig",
  1813. ),
  1814. "test:mobileconfig",
  1815. )
  1816. expect(config.permission?.["*"]).toBe("ask")
  1817. expect(config.permission?.grep).toBe("allow")
  1818. expect(config.permission?.webfetch).toBe("ask")
  1819. expect(config.permission?.["~/.ssh/*"]).toBe("deny")
  1820. const bash = config.permission?.bash as Record<string, string>
  1821. expect(bash?.["rm -rf *"]).toBe("deny")
  1822. expect(bash?.["curl *"]).toBe("deny")
  1823. })
  1824. test("parseManagedPlist parses enabled_providers", async () => {
  1825. const config = ConfigParse.schema(
  1826. ConfigV1.Info,
  1827. ConfigParse.jsonc(
  1828. await ConfigManaged.parseManagedPlist(
  1829. JSON.stringify({
  1830. $schema: "https://kirincode.ai/config.json",
  1831. enabled_providers: ["anthropic", "google"],
  1832. }),
  1833. ),
  1834. "test:mobileconfig",
  1835. ),
  1836. "test:mobileconfig",
  1837. )
  1838. expect(config.enabled_providers).toEqual(["anthropic", "google"])
  1839. })
  1840. test("parseManagedPlist handles empty config", async () => {
  1841. const config = ConfigParse.schema(
  1842. ConfigV1.Info,
  1843. ConfigParse.jsonc(
  1844. await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://kirincode.ai/config.json" })),
  1845. "test:mobileconfig",
  1846. ),
  1847. "test:mobileconfig",
  1848. )
  1849. expect(config.$schema).toBe("https://kirincode.ai/config.json")
  1850. })