config.test.ts 69 KB

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