config.test.ts 30 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147
  1. import { test, expect, mock, afterEach } from "bun:test"
  2. import { Config } from "../../src/config/config"
  3. import { Instance } from "../../src/project/instance"
  4. import { Auth } from "../../src/auth"
  5. import { tmpdir } from "../fixture/fixture"
  6. import path from "path"
  7. import fs from "fs/promises"
  8. import { pathToFileURL } from "url"
  9. test("loads config with defaults when no files exist", async () => {
  10. await using tmp = await tmpdir()
  11. await Instance.provide({
  12. directory: tmp.path,
  13. fn: async () => {
  14. const config = await Config.get()
  15. expect(config.username).toBeDefined()
  16. },
  17. })
  18. })
  19. test("loads JSON config file", async () => {
  20. await using tmp = await tmpdir({
  21. init: async (dir) => {
  22. await Bun.write(
  23. path.join(dir, "opencode.json"),
  24. JSON.stringify({
  25. $schema: "https://opencode.ai/config.json",
  26. model: "test/model",
  27. username: "testuser",
  28. }),
  29. )
  30. },
  31. })
  32. await Instance.provide({
  33. directory: tmp.path,
  34. fn: async () => {
  35. const config = await Config.get()
  36. expect(config.model).toBe("test/model")
  37. expect(config.username).toBe("testuser")
  38. },
  39. })
  40. })
  41. test("loads JSONC config file", async () => {
  42. await using tmp = await tmpdir({
  43. init: async (dir) => {
  44. await Bun.write(
  45. path.join(dir, "opencode.jsonc"),
  46. `{
  47. // This is a comment
  48. "$schema": "https://opencode.ai/config.json",
  49. "model": "test/model",
  50. "username": "testuser"
  51. }`,
  52. )
  53. },
  54. })
  55. await Instance.provide({
  56. directory: tmp.path,
  57. fn: async () => {
  58. const config = await Config.get()
  59. expect(config.model).toBe("test/model")
  60. expect(config.username).toBe("testuser")
  61. },
  62. })
  63. })
  64. test("merges multiple config files with correct precedence", async () => {
  65. await using tmp = await tmpdir({
  66. init: async (dir) => {
  67. await Bun.write(
  68. path.join(dir, "opencode.jsonc"),
  69. JSON.stringify({
  70. $schema: "https://opencode.ai/config.json",
  71. model: "base",
  72. username: "base",
  73. }),
  74. )
  75. await Bun.write(
  76. path.join(dir, "opencode.json"),
  77. JSON.stringify({
  78. $schema: "https://opencode.ai/config.json",
  79. model: "override",
  80. }),
  81. )
  82. },
  83. })
  84. await Instance.provide({
  85. directory: tmp.path,
  86. fn: async () => {
  87. const config = await Config.get()
  88. expect(config.model).toBe("override")
  89. expect(config.username).toBe("base")
  90. },
  91. })
  92. })
  93. test("handles environment variable substitution", async () => {
  94. const originalEnv = process.env["TEST_VAR"]
  95. process.env["TEST_VAR"] = "test_theme"
  96. try {
  97. await using tmp = await tmpdir({
  98. init: async (dir) => {
  99. await Bun.write(
  100. path.join(dir, "opencode.json"),
  101. JSON.stringify({
  102. $schema: "https://opencode.ai/config.json",
  103. theme: "{env:TEST_VAR}",
  104. }),
  105. )
  106. },
  107. })
  108. await Instance.provide({
  109. directory: tmp.path,
  110. fn: async () => {
  111. const config = await Config.get()
  112. expect(config.theme).toBe("test_theme")
  113. },
  114. })
  115. } finally {
  116. if (originalEnv !== undefined) {
  117. process.env["TEST_VAR"] = originalEnv
  118. } else {
  119. delete process.env["TEST_VAR"]
  120. }
  121. }
  122. })
  123. test("handles file inclusion substitution", async () => {
  124. await using tmp = await tmpdir({
  125. init: async (dir) => {
  126. await Bun.write(path.join(dir, "included.txt"), "test_theme")
  127. await Bun.write(
  128. path.join(dir, "opencode.json"),
  129. JSON.stringify({
  130. $schema: "https://opencode.ai/config.json",
  131. theme: "{file:included.txt}",
  132. }),
  133. )
  134. },
  135. })
  136. await Instance.provide({
  137. directory: tmp.path,
  138. fn: async () => {
  139. const config = await Config.get()
  140. expect(config.theme).toBe("test_theme")
  141. },
  142. })
  143. })
  144. test("validates config schema and throws on invalid fields", async () => {
  145. await using tmp = await tmpdir({
  146. init: async (dir) => {
  147. await Bun.write(
  148. path.join(dir, "opencode.json"),
  149. JSON.stringify({
  150. $schema: "https://opencode.ai/config.json",
  151. invalid_field: "should cause error",
  152. }),
  153. )
  154. },
  155. })
  156. await Instance.provide({
  157. directory: tmp.path,
  158. fn: async () => {
  159. // Strict schema should throw an error for invalid fields
  160. await expect(Config.get()).rejects.toThrow()
  161. },
  162. })
  163. })
  164. test("throws error for invalid JSON", async () => {
  165. await using tmp = await tmpdir({
  166. init: async (dir) => {
  167. await Bun.write(path.join(dir, "opencode.json"), "{ invalid json }")
  168. },
  169. })
  170. await Instance.provide({
  171. directory: tmp.path,
  172. fn: async () => {
  173. await expect(Config.get()).rejects.toThrow()
  174. },
  175. })
  176. })
  177. test("handles agent configuration", async () => {
  178. await using tmp = await tmpdir({
  179. init: async (dir) => {
  180. await Bun.write(
  181. path.join(dir, "opencode.json"),
  182. JSON.stringify({
  183. $schema: "https://opencode.ai/config.json",
  184. agent: {
  185. test_agent: {
  186. model: "test/model",
  187. temperature: 0.7,
  188. description: "test agent",
  189. },
  190. },
  191. }),
  192. )
  193. },
  194. })
  195. await Instance.provide({
  196. directory: tmp.path,
  197. fn: async () => {
  198. const config = await Config.get()
  199. expect(config.agent?.["test_agent"]).toEqual(
  200. expect.objectContaining({
  201. model: "test/model",
  202. temperature: 0.7,
  203. description: "test agent",
  204. }),
  205. )
  206. },
  207. })
  208. })
  209. test("handles command configuration", async () => {
  210. await using tmp = await tmpdir({
  211. init: async (dir) => {
  212. await Bun.write(
  213. path.join(dir, "opencode.json"),
  214. JSON.stringify({
  215. $schema: "https://opencode.ai/config.json",
  216. command: {
  217. test_command: {
  218. template: "test template",
  219. description: "test command",
  220. agent: "test_agent",
  221. },
  222. },
  223. }),
  224. )
  225. },
  226. })
  227. await Instance.provide({
  228. directory: tmp.path,
  229. fn: async () => {
  230. const config = await Config.get()
  231. expect(config.command?.["test_command"]).toEqual({
  232. template: "test template",
  233. description: "test command",
  234. agent: "test_agent",
  235. })
  236. },
  237. })
  238. })
  239. test("migrates autoshare to share field", async () => {
  240. await using tmp = await tmpdir({
  241. init: async (dir) => {
  242. await Bun.write(
  243. path.join(dir, "opencode.json"),
  244. JSON.stringify({
  245. $schema: "https://opencode.ai/config.json",
  246. autoshare: true,
  247. }),
  248. )
  249. },
  250. })
  251. await Instance.provide({
  252. directory: tmp.path,
  253. fn: async () => {
  254. const config = await Config.get()
  255. expect(config.share).toBe("auto")
  256. expect(config.autoshare).toBe(true)
  257. },
  258. })
  259. })
  260. test("migrates mode field to agent field", async () => {
  261. await using tmp = await tmpdir({
  262. init: async (dir) => {
  263. await Bun.write(
  264. path.join(dir, "opencode.json"),
  265. JSON.stringify({
  266. $schema: "https://opencode.ai/config.json",
  267. mode: {
  268. test_mode: {
  269. model: "test/model",
  270. temperature: 0.5,
  271. },
  272. },
  273. }),
  274. )
  275. },
  276. })
  277. await Instance.provide({
  278. directory: tmp.path,
  279. fn: async () => {
  280. const config = await Config.get()
  281. expect(config.agent?.["test_mode"]).toEqual({
  282. model: "test/model",
  283. temperature: 0.5,
  284. mode: "primary",
  285. options: {},
  286. permission: {},
  287. })
  288. },
  289. })
  290. })
  291. test("loads config from .opencode directory", async () => {
  292. await using tmp = await tmpdir({
  293. init: async (dir) => {
  294. const opencodeDir = path.join(dir, ".opencode")
  295. await fs.mkdir(opencodeDir, { recursive: true })
  296. const agentDir = path.join(opencodeDir, "agent")
  297. await fs.mkdir(agentDir, { recursive: true })
  298. await Bun.write(
  299. path.join(agentDir, "test.md"),
  300. `---
  301. model: test/model
  302. ---
  303. Test agent prompt`,
  304. )
  305. },
  306. })
  307. await Instance.provide({
  308. directory: tmp.path,
  309. fn: async () => {
  310. const config = await Config.get()
  311. expect(config.agent?.["test"]).toEqual(
  312. expect.objectContaining({
  313. name: "test",
  314. model: "test/model",
  315. prompt: "Test agent prompt",
  316. }),
  317. )
  318. },
  319. })
  320. })
  321. test("updates config and writes to file", async () => {
  322. await using tmp = await tmpdir()
  323. await Instance.provide({
  324. directory: tmp.path,
  325. fn: async () => {
  326. const newConfig = { model: "updated/model" }
  327. await Config.update(newConfig as any)
  328. const writtenConfig = JSON.parse(await Bun.file(path.join(tmp.path, "config.json")).text())
  329. expect(writtenConfig.model).toBe("updated/model")
  330. },
  331. })
  332. })
  333. test("gets config directories", async () => {
  334. await using tmp = await tmpdir()
  335. await Instance.provide({
  336. directory: tmp.path,
  337. fn: async () => {
  338. const dirs = await Config.directories()
  339. expect(dirs.length).toBeGreaterThanOrEqual(1)
  340. },
  341. })
  342. })
  343. test("resolves scoped npm plugins in config", async () => {
  344. await using tmp = await tmpdir({
  345. init: async (dir) => {
  346. const pluginDir = path.join(dir, "node_modules", "@scope", "plugin")
  347. await fs.mkdir(pluginDir, { recursive: true })
  348. await Bun.write(
  349. path.join(dir, "package.json"),
  350. JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
  351. )
  352. await Bun.write(
  353. path.join(pluginDir, "package.json"),
  354. JSON.stringify(
  355. {
  356. name: "@scope/plugin",
  357. version: "1.0.0",
  358. type: "module",
  359. main: "./index.js",
  360. },
  361. null,
  362. 2,
  363. ),
  364. )
  365. await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n")
  366. await Bun.write(
  367. path.join(dir, "opencode.json"),
  368. JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2),
  369. )
  370. },
  371. })
  372. await Instance.provide({
  373. directory: tmp.path,
  374. fn: async () => {
  375. const config = await Config.get()
  376. const pluginEntries = config.plugin ?? []
  377. const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
  378. const expected = import.meta.resolve("@scope/plugin", baseUrl)
  379. expect(pluginEntries.includes(expected)).toBe(true)
  380. const scopedEntry = pluginEntries.find((entry) => entry === expected)
  381. expect(scopedEntry).toBeDefined()
  382. expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
  383. },
  384. })
  385. })
  386. test("merges plugin arrays from global and local configs", async () => {
  387. await using tmp = await tmpdir({
  388. init: async (dir) => {
  389. // Create a nested project structure with local .opencode config
  390. const projectDir = path.join(dir, "project")
  391. const opencodeDir = path.join(projectDir, ".opencode")
  392. await fs.mkdir(opencodeDir, { recursive: true })
  393. // Global config with plugins
  394. await Bun.write(
  395. path.join(dir, "opencode.json"),
  396. JSON.stringify({
  397. $schema: "https://opencode.ai/config.json",
  398. plugin: ["global-plugin-1", "global-plugin-2"],
  399. }),
  400. )
  401. // Local .opencode config with different plugins
  402. await Bun.write(
  403. path.join(opencodeDir, "opencode.json"),
  404. JSON.stringify({
  405. $schema: "https://opencode.ai/config.json",
  406. plugin: ["local-plugin-1"],
  407. }),
  408. )
  409. },
  410. })
  411. await Instance.provide({
  412. directory: path.join(tmp.path, "project"),
  413. fn: async () => {
  414. const config = await Config.get()
  415. const plugins = config.plugin ?? []
  416. // Should contain both global and local plugins
  417. expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
  418. expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true)
  419. expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
  420. // Should have all 3 plugins (not replaced, but merged)
  421. const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin"))
  422. expect(pluginNames.length).toBeGreaterThanOrEqual(3)
  423. },
  424. })
  425. })
  426. test("does not error when only custom agent is a subagent", async () => {
  427. await using tmp = await tmpdir({
  428. init: async (dir) => {
  429. const opencodeDir = path.join(dir, ".opencode")
  430. await fs.mkdir(opencodeDir, { recursive: true })
  431. const agentDir = path.join(opencodeDir, "agent")
  432. await fs.mkdir(agentDir, { recursive: true })
  433. await Bun.write(
  434. path.join(agentDir, "helper.md"),
  435. `---
  436. model: test/model
  437. mode: subagent
  438. ---
  439. Helper subagent prompt`,
  440. )
  441. },
  442. })
  443. await Instance.provide({
  444. directory: tmp.path,
  445. fn: async () => {
  446. const config = await Config.get()
  447. expect(config.agent?.["helper"]).toMatchObject({
  448. name: "helper",
  449. model: "test/model",
  450. mode: "subagent",
  451. prompt: "Helper subagent prompt",
  452. })
  453. },
  454. })
  455. })
  456. test("merges instructions arrays from global and local configs", async () => {
  457. await using tmp = await tmpdir({
  458. init: async (dir) => {
  459. const projectDir = path.join(dir, "project")
  460. const opencodeDir = path.join(projectDir, ".opencode")
  461. await fs.mkdir(opencodeDir, { recursive: true })
  462. await Bun.write(
  463. path.join(dir, "opencode.json"),
  464. JSON.stringify({
  465. $schema: "https://opencode.ai/config.json",
  466. instructions: ["global-instructions.md", "shared-rules.md"],
  467. }),
  468. )
  469. await Bun.write(
  470. path.join(opencodeDir, "opencode.json"),
  471. JSON.stringify({
  472. $schema: "https://opencode.ai/config.json",
  473. instructions: ["local-instructions.md"],
  474. }),
  475. )
  476. },
  477. })
  478. await Instance.provide({
  479. directory: path.join(tmp.path, "project"),
  480. fn: async () => {
  481. const config = await Config.get()
  482. const instructions = config.instructions ?? []
  483. expect(instructions).toContain("global-instructions.md")
  484. expect(instructions).toContain("shared-rules.md")
  485. expect(instructions).toContain("local-instructions.md")
  486. expect(instructions.length).toBe(3)
  487. },
  488. })
  489. })
  490. test("deduplicates duplicate instructions from global and local configs", async () => {
  491. await using tmp = await tmpdir({
  492. init: async (dir) => {
  493. const projectDir = path.join(dir, "project")
  494. const opencodeDir = path.join(projectDir, ".opencode")
  495. await fs.mkdir(opencodeDir, { recursive: true })
  496. await Bun.write(
  497. path.join(dir, "opencode.json"),
  498. JSON.stringify({
  499. $schema: "https://opencode.ai/config.json",
  500. instructions: ["duplicate.md", "global-only.md"],
  501. }),
  502. )
  503. await Bun.write(
  504. path.join(opencodeDir, "opencode.json"),
  505. JSON.stringify({
  506. $schema: "https://opencode.ai/config.json",
  507. instructions: ["duplicate.md", "local-only.md"],
  508. }),
  509. )
  510. },
  511. })
  512. await Instance.provide({
  513. directory: path.join(tmp.path, "project"),
  514. fn: async () => {
  515. const config = await Config.get()
  516. const instructions = config.instructions ?? []
  517. expect(instructions).toContain("global-only.md")
  518. expect(instructions).toContain("local-only.md")
  519. expect(instructions).toContain("duplicate.md")
  520. const duplicates = instructions.filter((i) => i === "duplicate.md")
  521. expect(duplicates.length).toBe(1)
  522. expect(instructions.length).toBe(3)
  523. },
  524. })
  525. })
  526. test("deduplicates duplicate plugins from global and local configs", async () => {
  527. await using tmp = await tmpdir({
  528. init: async (dir) => {
  529. // Create a nested project structure with local .opencode config
  530. const projectDir = path.join(dir, "project")
  531. const opencodeDir = path.join(projectDir, ".opencode")
  532. await fs.mkdir(opencodeDir, { recursive: true })
  533. // Global config with plugins
  534. await Bun.write(
  535. path.join(dir, "opencode.json"),
  536. JSON.stringify({
  537. $schema: "https://opencode.ai/config.json",
  538. plugin: ["duplicate-plugin", "global-plugin-1"],
  539. }),
  540. )
  541. // Local .opencode config with some overlapping plugins
  542. await Bun.write(
  543. path.join(opencodeDir, "opencode.json"),
  544. JSON.stringify({
  545. $schema: "https://opencode.ai/config.json",
  546. plugin: ["duplicate-plugin", "local-plugin-1"],
  547. }),
  548. )
  549. },
  550. })
  551. await Instance.provide({
  552. directory: path.join(tmp.path, "project"),
  553. fn: async () => {
  554. const config = await Config.get()
  555. const plugins = config.plugin ?? []
  556. // Should contain all unique plugins
  557. expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
  558. expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
  559. expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true)
  560. // Should deduplicate the duplicate plugin
  561. const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin"))
  562. expect(duplicatePlugins.length).toBe(1)
  563. // Should have exactly 3 unique plugins
  564. const pluginNames = plugins.filter(
  565. (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
  566. )
  567. expect(pluginNames.length).toBe(3)
  568. },
  569. })
  570. })
  571. // Legacy tools migration tests
  572. test("migrates legacy tools config to permissions - allow", async () => {
  573. await using tmp = await tmpdir({
  574. init: async (dir) => {
  575. await Bun.write(
  576. path.join(dir, "opencode.json"),
  577. JSON.stringify({
  578. $schema: "https://opencode.ai/config.json",
  579. agent: {
  580. test: {
  581. tools: {
  582. bash: true,
  583. read: true,
  584. },
  585. },
  586. },
  587. }),
  588. )
  589. },
  590. })
  591. await Instance.provide({
  592. directory: tmp.path,
  593. fn: async () => {
  594. const config = await Config.get()
  595. expect(config.agent?.["test"]?.permission).toEqual({
  596. bash: "allow",
  597. read: "allow",
  598. })
  599. },
  600. })
  601. })
  602. test("migrates legacy tools config to permissions - deny", async () => {
  603. await using tmp = await tmpdir({
  604. init: async (dir) => {
  605. await Bun.write(
  606. path.join(dir, "opencode.json"),
  607. JSON.stringify({
  608. $schema: "https://opencode.ai/config.json",
  609. agent: {
  610. test: {
  611. tools: {
  612. bash: false,
  613. webfetch: false,
  614. },
  615. },
  616. },
  617. }),
  618. )
  619. },
  620. })
  621. await Instance.provide({
  622. directory: tmp.path,
  623. fn: async () => {
  624. const config = await Config.get()
  625. expect(config.agent?.["test"]?.permission).toEqual({
  626. bash: "deny",
  627. webfetch: "deny",
  628. })
  629. },
  630. })
  631. })
  632. test("migrates legacy write tool to edit permission", async () => {
  633. await using tmp = await tmpdir({
  634. init: async (dir) => {
  635. await Bun.write(
  636. path.join(dir, "opencode.json"),
  637. JSON.stringify({
  638. $schema: "https://opencode.ai/config.json",
  639. agent: {
  640. test: {
  641. tools: {
  642. write: true,
  643. },
  644. },
  645. },
  646. }),
  647. )
  648. },
  649. })
  650. await Instance.provide({
  651. directory: tmp.path,
  652. fn: async () => {
  653. const config = await Config.get()
  654. expect(config.agent?.["test"]?.permission).toEqual({
  655. edit: "allow",
  656. })
  657. },
  658. })
  659. })
  660. test("migrates legacy edit tool to edit permission", async () => {
  661. await using tmp = await tmpdir({
  662. init: async (dir) => {
  663. await Bun.write(
  664. path.join(dir, "opencode.json"),
  665. JSON.stringify({
  666. $schema: "https://opencode.ai/config.json",
  667. agent: {
  668. test: {
  669. tools: {
  670. edit: false,
  671. },
  672. },
  673. },
  674. }),
  675. )
  676. },
  677. })
  678. await Instance.provide({
  679. directory: tmp.path,
  680. fn: async () => {
  681. const config = await Config.get()
  682. expect(config.agent?.["test"]?.permission).toEqual({
  683. edit: "deny",
  684. })
  685. },
  686. })
  687. })
  688. test("migrates legacy patch tool to edit permission", async () => {
  689. await using tmp = await tmpdir({
  690. init: async (dir) => {
  691. await Bun.write(
  692. path.join(dir, "opencode.json"),
  693. JSON.stringify({
  694. $schema: "https://opencode.ai/config.json",
  695. agent: {
  696. test: {
  697. tools: {
  698. patch: true,
  699. },
  700. },
  701. },
  702. }),
  703. )
  704. },
  705. })
  706. await Instance.provide({
  707. directory: tmp.path,
  708. fn: async () => {
  709. const config = await Config.get()
  710. expect(config.agent?.["test"]?.permission).toEqual({
  711. edit: "allow",
  712. })
  713. },
  714. })
  715. })
  716. test("migrates legacy multiedit tool to edit permission", async () => {
  717. await using tmp = await tmpdir({
  718. init: async (dir) => {
  719. await Bun.write(
  720. path.join(dir, "opencode.json"),
  721. JSON.stringify({
  722. $schema: "https://opencode.ai/config.json",
  723. agent: {
  724. test: {
  725. tools: {
  726. multiedit: false,
  727. },
  728. },
  729. },
  730. }),
  731. )
  732. },
  733. })
  734. await Instance.provide({
  735. directory: tmp.path,
  736. fn: async () => {
  737. const config = await Config.get()
  738. expect(config.agent?.["test"]?.permission).toEqual({
  739. edit: "deny",
  740. })
  741. },
  742. })
  743. })
  744. test("migrates mixed legacy tools config", async () => {
  745. await using tmp = await tmpdir({
  746. init: async (dir) => {
  747. await Bun.write(
  748. path.join(dir, "opencode.json"),
  749. JSON.stringify({
  750. $schema: "https://opencode.ai/config.json",
  751. agent: {
  752. test: {
  753. tools: {
  754. bash: true,
  755. write: true,
  756. read: false,
  757. webfetch: true,
  758. },
  759. },
  760. },
  761. }),
  762. )
  763. },
  764. })
  765. await Instance.provide({
  766. directory: tmp.path,
  767. fn: async () => {
  768. const config = await Config.get()
  769. expect(config.agent?.["test"]?.permission).toEqual({
  770. bash: "allow",
  771. edit: "allow",
  772. read: "deny",
  773. webfetch: "allow",
  774. })
  775. },
  776. })
  777. })
  778. test("merges legacy tools with existing permission config", async () => {
  779. await using tmp = await tmpdir({
  780. init: async (dir) => {
  781. await Bun.write(
  782. path.join(dir, "opencode.json"),
  783. JSON.stringify({
  784. $schema: "https://opencode.ai/config.json",
  785. agent: {
  786. test: {
  787. permission: {
  788. glob: "allow",
  789. },
  790. tools: {
  791. bash: true,
  792. },
  793. },
  794. },
  795. }),
  796. )
  797. },
  798. })
  799. await Instance.provide({
  800. directory: tmp.path,
  801. fn: async () => {
  802. const config = await Config.get()
  803. expect(config.agent?.["test"]?.permission).toEqual({
  804. glob: "allow",
  805. bash: "allow",
  806. })
  807. },
  808. })
  809. })
  810. test("permission config preserves key order", async () => {
  811. await using tmp = await tmpdir({
  812. init: async (dir) => {
  813. await Bun.write(
  814. path.join(dir, "opencode.json"),
  815. JSON.stringify({
  816. $schema: "https://opencode.ai/config.json",
  817. permission: {
  818. "*": "deny",
  819. edit: "ask",
  820. write: "ask",
  821. external_directory: "ask",
  822. read: "allow",
  823. todowrite: "allow",
  824. todoread: "allow",
  825. "thoughts_*": "allow",
  826. "reasoning_model_*": "allow",
  827. "tools_*": "allow",
  828. "pr_comments_*": "allow",
  829. },
  830. }),
  831. )
  832. },
  833. })
  834. await Instance.provide({
  835. directory: tmp.path,
  836. fn: async () => {
  837. const config = await Config.get()
  838. expect(Object.keys(config.permission!)).toEqual([
  839. "*",
  840. "edit",
  841. "write",
  842. "external_directory",
  843. "read",
  844. "todowrite",
  845. "todoread",
  846. "thoughts_*",
  847. "reasoning_model_*",
  848. "tools_*",
  849. "pr_comments_*",
  850. ])
  851. },
  852. })
  853. })
  854. // MCP config merging tests
  855. test("project config can override MCP server enabled status", async () => {
  856. await using tmp = await tmpdir({
  857. init: async (dir) => {
  858. // Simulates a base config (like from remote .well-known) with disabled MCP
  859. await Bun.write(
  860. path.join(dir, "opencode.jsonc"),
  861. JSON.stringify({
  862. $schema: "https://opencode.ai/config.json",
  863. mcp: {
  864. jira: {
  865. type: "remote",
  866. url: "https://jira.example.com/mcp",
  867. enabled: false,
  868. },
  869. wiki: {
  870. type: "remote",
  871. url: "https://wiki.example.com/mcp",
  872. enabled: false,
  873. },
  874. },
  875. }),
  876. )
  877. // Project config enables just jira
  878. await Bun.write(
  879. path.join(dir, "opencode.json"),
  880. JSON.stringify({
  881. $schema: "https://opencode.ai/config.json",
  882. mcp: {
  883. jira: {
  884. type: "remote",
  885. url: "https://jira.example.com/mcp",
  886. enabled: true,
  887. },
  888. },
  889. }),
  890. )
  891. },
  892. })
  893. await Instance.provide({
  894. directory: tmp.path,
  895. fn: async () => {
  896. const config = await Config.get()
  897. // jira should be enabled (overridden by project config)
  898. expect(config.mcp?.jira).toEqual({
  899. type: "remote",
  900. url: "https://jira.example.com/mcp",
  901. enabled: true,
  902. })
  903. // wiki should still be disabled (not overridden)
  904. expect(config.mcp?.wiki).toEqual({
  905. type: "remote",
  906. url: "https://wiki.example.com/mcp",
  907. enabled: false,
  908. })
  909. },
  910. })
  911. })
  912. test("MCP config deep merges preserving base config properties", async () => {
  913. await using tmp = await tmpdir({
  914. init: async (dir) => {
  915. // Base config with full MCP definition
  916. await Bun.write(
  917. path.join(dir, "opencode.jsonc"),
  918. JSON.stringify({
  919. $schema: "https://opencode.ai/config.json",
  920. mcp: {
  921. myserver: {
  922. type: "remote",
  923. url: "https://myserver.example.com/mcp",
  924. enabled: false,
  925. headers: {
  926. "X-Custom-Header": "value",
  927. },
  928. },
  929. },
  930. }),
  931. )
  932. // Override just enables it, should preserve other properties
  933. await Bun.write(
  934. path.join(dir, "opencode.json"),
  935. JSON.stringify({
  936. $schema: "https://opencode.ai/config.json",
  937. mcp: {
  938. myserver: {
  939. type: "remote",
  940. url: "https://myserver.example.com/mcp",
  941. enabled: true,
  942. },
  943. },
  944. }),
  945. )
  946. },
  947. })
  948. await Instance.provide({
  949. directory: tmp.path,
  950. fn: async () => {
  951. const config = await Config.get()
  952. expect(config.mcp?.myserver).toEqual({
  953. type: "remote",
  954. url: "https://myserver.example.com/mcp",
  955. enabled: true,
  956. headers: {
  957. "X-Custom-Header": "value",
  958. },
  959. })
  960. },
  961. })
  962. })
  963. test("local .opencode config can override MCP from project config", async () => {
  964. await using tmp = await tmpdir({
  965. init: async (dir) => {
  966. // Project config with disabled MCP
  967. await Bun.write(
  968. path.join(dir, "opencode.json"),
  969. JSON.stringify({
  970. $schema: "https://opencode.ai/config.json",
  971. mcp: {
  972. docs: {
  973. type: "remote",
  974. url: "https://docs.example.com/mcp",
  975. enabled: false,
  976. },
  977. },
  978. }),
  979. )
  980. // Local .opencode directory config enables it
  981. const opencodeDir = path.join(dir, ".opencode")
  982. await fs.mkdir(opencodeDir, { recursive: true })
  983. await Bun.write(
  984. path.join(opencodeDir, "opencode.json"),
  985. JSON.stringify({
  986. $schema: "https://opencode.ai/config.json",
  987. mcp: {
  988. docs: {
  989. type: "remote",
  990. url: "https://docs.example.com/mcp",
  991. enabled: true,
  992. },
  993. },
  994. }),
  995. )
  996. },
  997. })
  998. await Instance.provide({
  999. directory: tmp.path,
  1000. fn: async () => {
  1001. const config = await Config.get()
  1002. expect(config.mcp?.docs?.enabled).toBe(true)
  1003. },
  1004. })
  1005. })
  1006. test("project config overrides remote well-known config", async () => {
  1007. const originalFetch = globalThis.fetch
  1008. let fetchedUrl: string | undefined
  1009. const mockFetch = mock((url: string | URL | Request) => {
  1010. const urlStr = url.toString()
  1011. if (urlStr.includes(".well-known/opencode")) {
  1012. fetchedUrl = urlStr
  1013. return Promise.resolve(
  1014. new Response(
  1015. JSON.stringify({
  1016. config: {
  1017. mcp: {
  1018. jira: {
  1019. type: "remote",
  1020. url: "https://jira.example.com/mcp",
  1021. enabled: false,
  1022. },
  1023. },
  1024. },
  1025. }),
  1026. { status: 200 },
  1027. ),
  1028. )
  1029. }
  1030. return originalFetch(url)
  1031. })
  1032. globalThis.fetch = mockFetch as unknown as typeof fetch
  1033. const originalAuthAll = Auth.all
  1034. Auth.all = mock(() =>
  1035. Promise.resolve({
  1036. "https://example.com": {
  1037. type: "wellknown" as const,
  1038. key: "TEST_TOKEN",
  1039. token: "test-token",
  1040. },
  1041. }),
  1042. )
  1043. try {
  1044. await using tmp = await tmpdir({
  1045. git: true,
  1046. init: async (dir) => {
  1047. // Project config enables jira (overriding remote default)
  1048. await Bun.write(
  1049. path.join(dir, "opencode.json"),
  1050. JSON.stringify({
  1051. $schema: "https://opencode.ai/config.json",
  1052. mcp: {
  1053. jira: {
  1054. type: "remote",
  1055. url: "https://jira.example.com/mcp",
  1056. enabled: true,
  1057. },
  1058. },
  1059. }),
  1060. )
  1061. },
  1062. })
  1063. await Instance.provide({
  1064. directory: tmp.path,
  1065. fn: async () => {
  1066. const config = await Config.get()
  1067. // Verify fetch was called for wellknown config
  1068. expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
  1069. // Project config (enabled: true) should override remote (enabled: false)
  1070. expect(config.mcp?.jira?.enabled).toBe(true)
  1071. },
  1072. })
  1073. } finally {
  1074. globalThis.fetch = originalFetch
  1075. Auth.all = originalAuthAll
  1076. }
  1077. })