config.test.ts 75 KB

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