config.test.ts 62 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517
  1. import path from "path"
  2. import fs from "fs/promises"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
  5. import { FastCheck } from "effect/testing"
  6. import { Config } from "@opencode-ai/core/config"
  7. import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
  8. import { ConfigModel } from "@opencode-ai/schema/config/model"
  9. import { ConfigProvider } from "@opencode-ai/schema/config/provider"
  10. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  11. import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
  12. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  13. import { Credential } from "@opencode-ai/core/credential"
  14. import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
  15. import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
  16. import { FSUtil } from "@opencode-ai/util/fs-util"
  17. import { Watcher } from "@opencode-ai/core/filesystem/watcher"
  18. import { Bus } from "@opencode-ai/core/bus"
  19. import { Global } from "@opencode-ai/util/global"
  20. import { Location } from "@opencode-ai/core/location"
  21. import { Project } from "@opencode-ai/core/project"
  22. import { Provider } from "@opencode-ai/core/provider"
  23. import { AbsolutePath } from "@opencode-ai/core/schema"
  24. import { WellKnown } from "@opencode-ai/core/wellknown"
  25. import { Integration } from "@opencode-ai/schema/integration"
  26. import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
  27. import { location } from "../fixture/location"
  28. import { tmpdir } from "../fixture/tmpdir"
  29. import { testEffect } from "../lib/effect"
  30. const it = testEffect(Layer.empty)
  31. const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
  32. function testLayer(
  33. directory: string,
  34. globalDirectory = path.join(directory, "global"),
  35. projectDirectory = directory,
  36. vcs?: Project.Vcs,
  37. watcher: Layer.Layer<Watcher.Service | Watcher.Test> = Watcher.testLayer,
  38. credentialNode = emptyCredentialNode,
  39. wellknownNode = emptyWellknownNode,
  40. options?: Config.Options,
  41. ) {
  42. const locationLayer = Layer.succeed(
  43. Location.Service,
  44. Location.Service.of(
  45. location(
  46. { directory: AbsolutePath.make(directory) },
  47. { projectDirectory: AbsolutePath.make(projectDirectory), vcs },
  48. ),
  49. ),
  50. )
  51. const built = AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
  52. [Config.node, Config.configured(options)],
  53. [Location.node, locationLayer],
  54. [Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
  55. [Credential.node, credentialNode],
  56. [WellKnown.node, wellknownNode],
  57. [Watcher.node, watcher],
  58. ])
  59. // Merge the watcher layer by reference so Watcher.Test resolves to the same
  60. // memoized instance the built graph uses.
  61. return Layer.mergeAll(built, watcher)
  62. }
  63. const provider = {
  64. package: "native",
  65. settings: {},
  66. headers: {},
  67. body: {},
  68. models: {},
  69. }
  70. describe("Config", () => {
  71. it.live("loads explicit file and content overrides in priority order", () =>
  72. Effect.acquireRelease(
  73. Effect.promise(() => tmpdir()),
  74. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  75. ).pipe(
  76. Effect.flatMap((tmp) => {
  77. const global = path.join(tmp.path, "global")
  78. const project = path.join(tmp.path, "project")
  79. const explicit = path.join(tmp.path, "custom.json")
  80. return Effect.promise(async () => {
  81. await fs.mkdir(global, { recursive: true })
  82. await fs.mkdir(project, { recursive: true })
  83. await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
  84. await fs.writeFile(explicit, JSON.stringify({ shell: "explicit" }))
  85. await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
  86. }).pipe(
  87. Effect.andThen(
  88. Effect.gen(function* () {
  89. const config = yield* Config.Service
  90. const entries = yield* config.entries()
  91. expect(
  92. entries.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
  93. ).toEqual(["global", "explicit", "project", "content"])
  94. expect(Config.latest(entries, "shell")).toBe("content")
  95. }).pipe(
  96. Effect.provide(
  97. testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
  98. file: explicit,
  99. content: JSON.stringify({ shell: "content" }),
  100. }),
  101. ),
  102. ),
  103. ),
  104. )
  105. }),
  106. ),
  107. )
  108. it.live("skips project configuration when project discovery is disabled", () =>
  109. Effect.acquireRelease(
  110. Effect.promise(() => tmpdir()),
  111. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  112. ).pipe(
  113. Effect.flatMap((tmp) => {
  114. const global = path.join(tmp.path, "global")
  115. const project = path.join(tmp.path, "project")
  116. return Effect.promise(async () => {
  117. await fs.mkdir(global, { recursive: true })
  118. await fs.mkdir(project, { recursive: true })
  119. await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
  120. await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
  121. }).pipe(
  122. Effect.andThen(
  123. Effect.gen(function* () {
  124. const config = yield* Config.Service
  125. expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
  126. }).pipe(
  127. Effect.provide(
  128. testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
  129. project: false,
  130. }),
  131. ),
  132. ),
  133. ),
  134. )
  135. }),
  136. ),
  137. )
  138. it.live("reloads external config and publishes directory updates", () =>
  139. Effect.acquireRelease(
  140. Effect.promise(() => tmpdir()),
  141. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  142. ).pipe(
  143. Effect.flatMap((tmp) =>
  144. Effect.gen(function* () {
  145. const global = path.join(tmp.path, "global")
  146. const project = path.join(tmp.path, "project")
  147. const file = path.join(global, "opencode.json")
  148. yield* Effect.promise(async () => {
  149. await fs.mkdir(global, { recursive: true })
  150. await fs.mkdir(project, { recursive: true })
  151. await fs.writeFile(file, JSON.stringify({ shell: "first" }))
  152. })
  153. return yield* Effect.gen(function* () {
  154. const config = yield* Config.Service
  155. const bus = yield* Bus.Service
  156. const watcher = yield* Watcher.Test
  157. const changed = yield* bus
  158. .subscribe(Event.Updated)
  159. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  160. yield* Effect.sleep("10 millis")
  161. yield* watcher.emit({ type: "update", path: path.join(global, "commands", "review.md") })
  162. yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
  163. yield* watcher.emit({ type: "update", path: file })
  164. expect(yield* Fiber.join(changed)).toHaveLength(1)
  165. expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
  166. }).pipe(Effect.provide(testLayer(project, global, project, undefined, Watcher.testLayer)))
  167. }),
  168. ),
  169. ),
  170. )
  171. it.live("exposes filesystem updates under config roots through changes", () =>
  172. Effect.acquireRelease(
  173. Effect.promise(() => tmpdir()),
  174. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  175. ).pipe(
  176. Effect.flatMap((tmp) =>
  177. Effect.gen(function* () {
  178. const global = path.join(tmp.path, "global")
  179. const project = path.join(tmp.path, "project")
  180. yield* Effect.promise(async () => {
  181. await fs.mkdir(path.join(global, "commands"), { recursive: true })
  182. await fs.mkdir(project, { recursive: true })
  183. })
  184. return yield* Effect.gen(function* () {
  185. const config = yield* Config.Service
  186. const watcher = yield* Watcher.Test
  187. const received = yield* config
  188. .changes()
  189. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
  190. yield* Effect.sleep("10 millis")
  191. const file = path.join(global, "commands", "review.md")
  192. yield* watcher.emit({ type: "update", path: file })
  193. const collected = yield* Fiber.join(received).pipe(Effect.timeout("1 second"))
  194. expect(Array.from(collected)).toEqual([{ type: "update", path: file }])
  195. }).pipe(Effect.provide(testLayer(project, global, project, undefined, Watcher.testLayer)))
  196. }),
  197. ),
  198. ),
  199. )
  200. // Real watcher on purpose: the regression this pins (a deleted config file's
  201. // watch being torn down, making recreation invisible) only reproduces with
  202. // path-faithful event delivery.
  203. it.live("keeps watching a deleted config file so recreating it reloads", () =>
  204. Effect.acquireRelease(
  205. Effect.promise(() => tmpdir()),
  206. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  207. ).pipe(
  208. Effect.flatMap((tmp) =>
  209. Effect.gen(function* () {
  210. const global = path.join(tmp.path, "global")
  211. const project = path.join(tmp.path, "project")
  212. const file = path.join(project, "opencode.json")
  213. yield* Effect.promise(async () => {
  214. await fs.mkdir(global, { recursive: true })
  215. await fs.mkdir(project, { recursive: true })
  216. await fs.writeFile(file, JSON.stringify({ shell: "one" }))
  217. })
  218. return yield* Effect.gen(function* () {
  219. const config = yield* Config.Service
  220. const bus = yield* Bus.Service
  221. expect(Config.latest(yield* config.entries(), "shell")).toBe("one")
  222. yield* Effect.sleep("10 millis")
  223. const removed = yield* bus
  224. .subscribe(Event.Updated)
  225. .pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
  226. yield* Effect.promise(() => fs.rm(file))
  227. yield* Fiber.join(removed).pipe(Effect.timeout("5 seconds"))
  228. expect(Config.latest(yield* config.entries(), "shell")).toBeUndefined()
  229. const recreated = yield* bus
  230. .subscribe(Event.Updated)
  231. .pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
  232. yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "two" })))
  233. yield* Fiber.join(recreated).pipe(Effect.timeout("5 seconds"))
  234. expect(Config.latest(yield* config.entries(), "shell")).toBe("two")
  235. }).pipe(
  236. Effect.provide(
  237. AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
  238. [
  239. Location.node,
  240. Layer.succeed(
  241. Location.Service,
  242. Location.Service.of(location({ directory: AbsolutePath.make(project) })),
  243. ),
  244. ],
  245. [Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
  246. [Credential.node, emptyCredentialNode],
  247. [WellKnown.node, emptyWellknownNode],
  248. ]),
  249. ),
  250. )
  251. }),
  252. ),
  253. ),
  254. )
  255. it.effect("backs Config.Service and Config.Test with one shared test implementation", () =>
  256. Effect.gen(function* () {
  257. const config = yield* Config.Service
  258. const test = yield* Config.Test
  259. expect(yield* config.entries()).toEqual([])
  260. const entry = new Document({ type: "document", info: new Info({}) })
  261. yield* test.setEntries([entry])
  262. expect(yield* config.entries()).toEqual([entry])
  263. const received = yield* config
  264. .changes()
  265. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
  266. yield* Effect.yieldNow
  267. yield* test.emitChange({ type: "create", path: "/root/commands/review.md" })
  268. expect(Array.from(yield* Fiber.join(received))).toEqual([{ type: "create", path: "/root/commands/review.md" }])
  269. }).pipe(Effect.provide(Config.testLayer())),
  270. )
  271. it.effect("returns the latest defined scalar from priority-ordered documents", () =>
  272. Effect.sync(() => {
  273. const entries = [
  274. new Document({
  275. type: "document",
  276. info: new Info({ model: selection("openrouter/openai/gpt-5") }),
  277. }),
  278. new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
  279. new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
  280. new Document({ type: "document", info: new Info({}) }),
  281. new Document({
  282. type: "document",
  283. info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
  284. }),
  285. ]
  286. expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
  287. expect(Config.latest(entries, "default_agent")).toBeUndefined()
  288. }),
  289. )
  290. it.live("loads authenticated wellknown config before user configuration", () =>
  291. Effect.acquireUseRelease(
  292. Effect.promise(() => tmpdir()),
  293. (tmp) =>
  294. Effect.gen(function* () {
  295. const global = path.join(tmp.path, "global")
  296. const project = path.join(tmp.path, "project")
  297. yield* Effect.promise(async () => {
  298. await fs.mkdir(global, { recursive: true })
  299. await fs.mkdir(project, { recursive: true })
  300. await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
  301. await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
  302. })
  303. const integrationID = Integration.ID.make("https://example.com")
  304. let key = "secret"
  305. const credentialNode = makeGlobalNode({
  306. service: Credential.Service,
  307. layer: Layer.succeed(
  308. Credential.Service,
  309. Credential.Service.of({
  310. all: () => Effect.die("unused Credential.all"),
  311. list: () =>
  312. Effect.succeed([
  313. new Credential.Info({
  314. id: Credential.ID.create(),
  315. integrationID,
  316. label: "default",
  317. value: Credential.Key.make({ type: "key", key }),
  318. }),
  319. ]),
  320. get: () => Effect.die("unused Credential.get"),
  321. create: () => Effect.die("unused Credential.create"),
  322. update: () => Effect.die("unused Credential.update"),
  323. remove: () => Effect.die("unused Credential.remove"),
  324. }),
  325. ),
  326. deps: [],
  327. })
  328. const entry: WellKnown.Entry = {
  329. origin: "https://example.com",
  330. integrationID,
  331. manifest: { auth: { command: ["login"], env: "TOKEN" } },
  332. }
  333. const wellknownNode = makeGlobalNode({
  334. service: WellKnown.Service,
  335. layer: Layer.succeed(
  336. WellKnown.Service,
  337. WellKnown.Service.of({
  338. entries: () => Effect.succeed([entry]),
  339. snapshot: () => [entry],
  340. refresh: () => Effect.succeed(false),
  341. add: () => Effect.die("unused Wellknown.add"),
  342. remove: () => Effect.die("unused Wellknown.remove"),
  343. resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]),
  344. }),
  345. ),
  346. deps: [],
  347. })
  348. return yield* Effect.gen(function* () {
  349. const config = yield* Config.Service
  350. const bus = yield* Bus.Service
  351. const initial = yield* config.entries()
  352. expect(Config.latest(initial, "shell")).toBe("project")
  353. expect(
  354. initial.flatMap((entry) =>
  355. entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
  356. ),
  357. ).toEqual(["secret", "global", "project"])
  358. const updated = yield* bus
  359. .subscribe(Event.Updated)
  360. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  361. yield* Effect.yieldNow
  362. key = "next"
  363. yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
  364. expect(yield* Fiber.join(updated)).toHaveLength(1)
  365. const refreshed = yield* config.entries()
  366. expect(Config.latest(refreshed, "shell")).toBe("project")
  367. expect(
  368. refreshed.flatMap((entry) =>
  369. entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
  370. ),
  371. ).toEqual(["next", "global", "project"])
  372. }).pipe(
  373. Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
  374. )
  375. }),
  376. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  377. ),
  378. )
  379. it.live("logs redacted source-aware diagnostics for every config source", () => {
  380. const output: Array<Record<string, unknown>> = []
  381. const logger = Logger.map(Logger.formatStructured, (entry) => {
  382. if (!Array.isArray(entry.message) || entry.message[0] !== "configuration normalization diagnostic") return
  383. const details = entry.message[1]
  384. if (typeof details === "object" && details !== null) output.push(details as Record<string, unknown>)
  385. })
  386. return Effect.acquireUseRelease(
  387. Effect.promise(() => tmpdir()),
  388. (tmp) =>
  389. Effect.gen(function* () {
  390. const global = path.join(tmp.path, "global")
  391. const project = path.join(tmp.path, "project")
  392. const malformed = path.join(tmp.path, "malformed.json")
  393. yield* Effect.promise(async () => {
  394. await fs.mkdir(global, { recursive: true })
  395. await fs.mkdir(project, { recursive: true })
  396. await fs.writeFile(path.join(global, "opencode.json"), "null")
  397. await fs.writeFile(path.join(project, "opencode.json"), "")
  398. await fs.writeFile(malformed, '{ "credential": "file-secret"')
  399. })
  400. const integrationID = Integration.ID.make("https://invalid.example.com")
  401. const entry: WellKnown.Entry = {
  402. origin: "https://invalid.example.com",
  403. integrationID,
  404. manifest: { auth: { command: ["login"], env: "TOKEN" } },
  405. }
  406. const credentialNode = makeGlobalNode({
  407. service: Credential.Service,
  408. layer: Layer.succeed(
  409. Credential.Service,
  410. Credential.Service.of({
  411. all: () => Effect.die("unused Credential.all"),
  412. list: () =>
  413. Effect.succeed([
  414. new Credential.Info({
  415. id: Credential.ID.create(),
  416. integrationID,
  417. label: "default",
  418. value: Credential.Key.make({ type: "key", key: "wellknown-secret" }),
  419. }),
  420. ]),
  421. get: () => Effect.die("unused Credential.get"),
  422. create: () => Effect.die("unused Credential.create"),
  423. update: () => Effect.die("unused Credential.update"),
  424. remove: () => Effect.die("unused Credential.remove"),
  425. }),
  426. ),
  427. deps: [],
  428. })
  429. const wellknownNode = makeGlobalNode({
  430. service: WellKnown.Service,
  431. layer: Layer.succeed(
  432. WellKnown.Service,
  433. WellKnown.Service.of({
  434. entries: () => Effect.succeed([entry]),
  435. snapshot: () => [entry],
  436. refresh: () => Effect.succeed(false),
  437. add: () => Effect.die("unused Wellknown.add"),
  438. remove: () => Effect.die("unused Wellknown.remove"),
  439. // Exercise the loader boundary against a malformed implementation response.
  440. resolve: () => Effect.succeed([null as unknown as WellKnown.Config]),
  441. }),
  442. ),
  443. deps: [],
  444. })
  445. yield* Config.Service.use((config) => config.entries()).pipe(
  446. Effect.provide(
  447. testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode, {
  448. file: malformed,
  449. content: "",
  450. }),
  451. ),
  452. )
  453. expect(output.map((item) => `${item.source}:${item.path}:${item.kind}`).toSorted()).toEqual(
  454. [
  455. `${path.join(global, "opencode.json")}:$:invalid`,
  456. `${path.join(project, "opencode.json")}:$:invalid`,
  457. `${malformed}:$:invalid`,
  458. "https://invalid.example.com:$:invalid",
  459. "OPENCODE_CONFIG_CONTENT:$:invalid",
  460. ].toSorted(),
  461. )
  462. expect(JSON.stringify(output)).not.toContain("secret")
  463. }),
  464. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  465. ).pipe(Effect.provide(Logger.layer([logger])))
  466. })
  467. it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
  468. Effect.sync(() => {
  469. FastCheck.assert(
  470. FastCheck.property(Schema.toArbitrary(ConfigV1.Info), (info) => {
  471. const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
  472. Schema.decodeUnknownSync(Schema.UnknownFromJsonString)(
  473. Schema.encodeUnknownSync(Schema.UnknownFromJsonString)(info),
  474. ),
  475. )
  476. Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
  477. }),
  478. { numRuns: 100 },
  479. )
  480. }),
  481. )
  482. it.effect("migrates the v1 experimental subagent depth", () =>
  483. Effect.sync(() => {
  484. expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
  485. }),
  486. )
  487. it.effect("migrates the v1 small model to the title agent", () =>
  488. Effect.sync(() => {
  489. expect(
  490. ConfigMigrateV1.migrate({
  491. small_model: "anthropic/claude-haiku-4-5",
  492. agent: { title: { prompt: "Custom title prompt" } },
  493. }).agents?.title,
  494. ).toEqual({
  495. model: { providerID: "anthropic", model: "claude-haiku-4-5" },
  496. system: "Custom title prompt",
  497. })
  498. }),
  499. )
  500. it.effect("migrates v1 provider lists to policies", () =>
  501. Effect.sync(() => {
  502. expect(
  503. ConfigMigrateV1.migrate({
  504. enabled_providers: ["anthropic", "openai"],
  505. disabled_providers: ["openai"],
  506. }).experimental?.policies,
  507. ).toEqual([
  508. { action: "provider.use", resource: "*", effect: "deny" },
  509. { action: "provider.use", resource: "anthropic", effect: "allow" },
  510. { action: "provider.use", resource: "openai", effect: "allow" },
  511. { action: "provider.use", resource: "openai", effect: "deny" },
  512. ])
  513. expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
  514. { action: "provider.use", resource: "*", effect: "deny" },
  515. ])
  516. }),
  517. )
  518. it.effect("migrates v1 provider setup options into AISDK settings", () =>
  519. Effect.sync(() => {
  520. const migrated = ConfigMigrateV1.migrate({
  521. provider: {
  522. bedrock: {
  523. npm: "@ai-sdk/amazon-bedrock",
  524. options: {
  525. headers: { "x-test": "1" },
  526. body: { trace: true },
  527. region: "us-east-1",
  528. profile: "dev",
  529. },
  530. },
  531. },
  532. })
  533. expect(migrated.providers?.bedrock).toMatchObject({
  534. package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
  535. settings: { region: "us-east-1", profile: "dev" },
  536. headers: { "x-test": "1" },
  537. body: { trace: true },
  538. })
  539. }),
  540. )
  541. it.effect("renames old provider IDs while migrating v1 configuration", () =>
  542. Effect.sync(() => {
  543. const migrated = ConfigMigrateV1.migrate({
  544. model: "azure-cognitive-services/deployment",
  545. enabled_providers: ["google-vertex-anthropic"],
  546. disabled_providers: ["azure-cognitive-services"],
  547. agent: {
  548. reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
  549. },
  550. command: {
  551. review: { template: "Review", model: "azure-cognitive-services/deployment" },
  552. },
  553. provider: {
  554. "azure-cognitive-services": {
  555. npm: "@ai-sdk/azure",
  556. env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
  557. models: { deployment: {} },
  558. },
  559. "google-vertex-anthropic": {
  560. npm: "@ai-sdk/google-vertex/anthropic",
  561. options: { project: "test-project", location: "us-central1" },
  562. models: { "claude-sonnet": {} },
  563. },
  564. },
  565. })
  566. expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
  567. expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
  568. expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
  569. expect(migrated.experimental?.policies).toEqual([
  570. { action: "provider.use", resource: "*", effect: "deny" },
  571. { action: "provider.use", resource: "google-vertex", effect: "allow" },
  572. { action: "provider.use", resource: "azure", effect: "deny" },
  573. ])
  574. expect(migrated.providers?.azure).toMatchObject({
  575. env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
  576. package: Provider.aisdk("@ai-sdk/azure"),
  577. models: { deployment: {} },
  578. })
  579. expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
  580. expect(migrated.providers?.["google-vertex"]).toMatchObject({
  581. settings: { project: "test-project", location: "us-central1" },
  582. models: {
  583. "claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
  584. },
  585. })
  586. expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
  587. expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
  588. }),
  589. )
  590. it.effect("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () =>
  591. Effect.sync(() => {
  592. const migrated = ConfigMigrateV1.migrate({
  593. provider: {
  594. "azure-cognitive-services": {
  595. npm: "@ai-sdk/openai-compatible",
  596. env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
  597. },
  598. },
  599. })
  600. expect(migrated.providers?.azure).toMatchObject({
  601. env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
  602. package: Provider.aisdk("@ai-sdk/openai-compatible"),
  603. settings: {
  604. baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
  605. },
  606. })
  607. }),
  608. )
  609. it.effect("ignores old provider IDs when the current provider ID is configured", () =>
  610. Effect.sync(() => {
  611. const migrated = ConfigMigrateV1.migrate({
  612. provider: {
  613. azure: { models: { current: {} } },
  614. "azure-cognitive-services": { models: { legacy: {} } },
  615. "google-vertex": { models: { gemini: {} } },
  616. "google-vertex-anthropic": { models: { claude: {} } },
  617. },
  618. })
  619. expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
  620. expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
  621. }),
  622. )
  623. it.effect("preserves the built-in package for v1 Vertex Anthropic custom models", () =>
  624. Effect.sync(() => {
  625. const migrated = ConfigMigrateV1.migrate({
  626. provider: {
  627. "google-vertex-anthropic": {
  628. models: { claude: {} },
  629. },
  630. },
  631. })
  632. expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
  633. expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
  634. Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
  635. )
  636. }),
  637. )
  638. it.effect("migrates v1 interleaved fields to compatibility", () =>
  639. Effect.sync(() => {
  640. const migrated = ConfigMigrateV1.migrate({
  641. provider: {
  642. custom: {
  643. models: {
  644. object: { interleaved: { field: "vendor_reasoning" } },
  645. string: { interleaved: "reasoning_text" },
  646. boolean: { interleaved: true },
  647. },
  648. },
  649. },
  650. })
  651. expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
  652. reasoningField: "vendor_reasoning",
  653. })
  654. expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
  655. expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
  656. }),
  657. )
  658. it.effect("migrates v1 command configuration", () =>
  659. Effect.sync(() => {
  660. expect(
  661. ConfigMigrateV1.migrate({
  662. command: {
  663. review: {
  664. template: "Review changes",
  665. description: "Review code",
  666. agent: "reviewer",
  667. model: "anthropic/claude",
  668. variant: "high",
  669. subtask: true,
  670. },
  671. },
  672. }).commands,
  673. ).toEqual({
  674. review: {
  675. template: "Review changes",
  676. description: "Review code",
  677. agent: "reviewer",
  678. model: { providerID: "anthropic", model: "claude", variant: "high" },
  679. subtask: true,
  680. },
  681. })
  682. }),
  683. )
  684. it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
  685. Effect.sync(() => {
  686. expect(
  687. ConfigMigrateV1.migrate({
  688. permission: {
  689. task: "ask",
  690. bash: { "git status": "allow", "*": "deny" },
  691. write: "deny",
  692. read: "allow",
  693. },
  694. }).permissions,
  695. ).toEqual([
  696. { action: "subagent", resource: "*", effect: "ask" },
  697. { action: "shell", resource: "git status", effect: "allow" },
  698. { action: "shell", resource: "*", effect: "deny" },
  699. { action: "edit", resource: "*", effect: "deny" },
  700. { action: "read", resource: "*", effect: "allow" },
  701. ])
  702. }),
  703. )
  704. it.live("returns an empty configuration when directory files do not exist", () =>
  705. Effect.acquireRelease(
  706. Effect.promise(() => tmpdir()),
  707. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  708. ).pipe(
  709. Effect.flatMap((tmp) =>
  710. Effect.gen(function* () {
  711. const config = yield* Config.Service
  712. const entries = yield* config.entries()
  713. expect(entries).toEqual([
  714. new Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
  715. ])
  716. }).pipe(Effect.provide(testLayer(tmp.path))),
  717. ),
  718. ),
  719. )
  720. it.live("deduplicates global ecosystem directories found during upward discovery", () =>
  721. Effect.acquireRelease(
  722. Effect.promise(() => tmpdir()),
  723. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  724. ).pipe(
  725. Effect.flatMap((tmp) =>
  726. Effect.gen(function* () {
  727. const global = path.join(tmp.path, "global")
  728. const home = path.join(global, "home")
  729. const project = path.join(home, "project")
  730. yield* Effect.promise(() =>
  731. Promise.all([
  732. fs.mkdir(path.join(home, ".claude"), { recursive: true }),
  733. fs.mkdir(path.join(home, ".agents"), { recursive: true }),
  734. fs.mkdir(project, { recursive: true }),
  735. ]),
  736. )
  737. const entries = yield* Config.Service.use((config) => config.entries()).pipe(
  738. Effect.provide(testLayer(project, global)),
  739. )
  740. expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
  741. AbsolutePath.make(path.join(home, ".claude")),
  742. ])
  743. expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
  744. AbsolutePath.make(path.join(home, ".agents")),
  745. ])
  746. }),
  747. ),
  748. ),
  749. )
  750. it.live("does not watch ecosystem config roots", () =>
  751. Effect.acquireRelease(
  752. Effect.promise(() => tmpdir()),
  753. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  754. ).pipe(
  755. Effect.flatMap((tmp) =>
  756. Effect.gen(function* () {
  757. yield* Effect.promise(() =>
  758. Promise.all([
  759. fs.mkdir(path.join(tmp.path, ".claude", "skills"), { recursive: true }),
  760. fs.mkdir(path.join(tmp.path, ".agents"), { recursive: true }),
  761. ]),
  762. )
  763. return yield* Effect.gen(function* () {
  764. const config = yield* Config.Service
  765. const watcher = yield* Watcher.Test
  766. yield* config.entries()
  767. expect(yield* watcher.subscriptions()).toEqual([
  768. {
  769. type: "directory",
  770. path: AbsolutePath.make(path.join(tmp.path, "global")),
  771. ignore: ["**/{node_modules,.git}/**", ".git", "node_modules"],
  772. },
  773. ])
  774. }).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, Watcher.testLayer)))
  775. }),
  776. ),
  777. ),
  778. )
  779. it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
  780. Effect.acquireRelease(
  781. Effect.promise(() => tmpdir()),
  782. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  783. ).pipe(
  784. Effect.flatMap((tmp) =>
  785. Effect.gen(function* () {
  786. yield* Effect.promise(() =>
  787. Promise.all([
  788. fs.writeFile(
  789. path.join(tmp.path, "opencode.json"),
  790. JSON.stringify({ $schema: "base", providers: { base: provider } }),
  791. ),
  792. fs.writeFile(
  793. path.join(tmp.path, "opencode.jsonc"),
  794. `{
  795. // Later global files override scalar fields while retaining providers.
  796. "$schema": "last",
  797. "providers": { "last": ${JSON.stringify(provider)} },
  798. }`,
  799. ),
  800. ]),
  801. )
  802. return yield* Effect.gen(function* () {
  803. const config = yield* Config.Service
  804. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  805. expect(documents).toHaveLength(2)
  806. expect(documents.map((document) => document.type)).toEqual(["document", "document"])
  807. expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"])
  808. expect(documents[0]).toBeInstanceOf(Document)
  809. expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json"))
  810. expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
  811. yield* Effect.promise(() =>
  812. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
  813. )
  814. expect(
  815. (yield* config.entries())
  816. .filter((entry) => entry.type === "document")
  817. .map((document) => document.info.$schema),
  818. ).toEqual(["base", "last"])
  819. }).pipe(Effect.provide(testLayer(tmp.path)))
  820. }),
  821. ),
  822. ),
  823. )
  824. it.live("substitutes environment variables and relative file contents", () =>
  825. Effect.acquireUseRelease(
  826. Effect.sync(() => {
  827. const previous = {
  828. token: process.env.OPENCODE_TEST_MCP_TOKEN,
  829. missing: process.env.OPENCODE_TEST_MISSING,
  830. }
  831. process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
  832. delete process.env.OPENCODE_TEST_MISSING
  833. return previous
  834. }),
  835. () =>
  836. Effect.acquireUseRelease(
  837. Effect.promise(() => tmpdir()),
  838. (tmp) =>
  839. Effect.gen(function* () {
  840. yield* Effect.promise(() =>
  841. Promise.all([
  842. fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'),
  843. fs.writeFile(
  844. path.join(tmp.path, "opencode.jsonc"),
  845. `{
  846. // Ignored reference: {file:missing.txt}
  847. "username": "user-{env:OPENCODE_TEST_MISSING}",
  848. "mcp": {
  849. "servers": {
  850. "remote": {
  851. "type": "remote",
  852. "url": "https://example.com/mcp",
  853. "headers": {
  854. "Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
  855. "X-Token": "{file:token.txt}"
  856. }
  857. }
  858. }
  859. }
  860. }`,
  861. ),
  862. ]),
  863. )
  864. return yield* Effect.gen(function* () {
  865. const config = yield* Config.Service
  866. const document = (yield* config.entries()).find((entry) => entry.type === "document")
  867. expect(document?.info.username).toBe("user-")
  868. const remote = document?.info.mcp?.servers?.remote
  869. expect(remote?.type).toBe("remote")
  870. if (remote?.type !== "remote") return
  871. expect(remote.headers).toEqual({
  872. Authorization: "Bearer secret",
  873. "X-Token": 'file\n"token"',
  874. })
  875. }).pipe(Effect.provide(testLayer(tmp.path)))
  876. }),
  877. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  878. ),
  879. (previous) =>
  880. Effect.sync(() => {
  881. if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN
  882. else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
  883. if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
  884. else process.env.OPENCODE_TEST_MISSING = previous.missing
  885. }),
  886. ),
  887. )
  888. it.live("does not load legacy config.json files", () =>
  889. Effect.acquireRelease(
  890. Effect.promise(() => tmpdir()),
  891. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  892. ).pipe(
  893. Effect.flatMap((tmp) =>
  894. Effect.gen(function* () {
  895. yield* Effect.promise(() =>
  896. fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "legacy" })),
  897. )
  898. return yield* Effect.gen(function* () {
  899. const config = yield* Config.Service
  900. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  901. expect(documents).toHaveLength(0)
  902. }).pipe(Effect.provide(testLayer(tmp.path)))
  903. }),
  904. ),
  905. ),
  906. )
  907. it.live("accepts $schema metadata without writing it into config files", () =>
  908. Effect.acquireRelease(
  909. Effect.promise(() => tmpdir()),
  910. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  911. ).pipe(
  912. Effect.flatMap((tmp) =>
  913. Effect.gen(function* () {
  914. const file = path.join(tmp.path, "opencode.json")
  915. const contents = JSON.stringify({
  916. shell: "/bin/zsh",
  917. providers: { local: provider },
  918. })
  919. yield* Effect.promise(() => fs.writeFile(file, contents))
  920. return yield* Effect.gen(function* () {
  921. const config = yield* Config.Service
  922. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  923. expect(documents[0]?.info.$schema).toBeUndefined()
  924. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  925. expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
  926. }).pipe(Effect.provide(testLayer(tmp.path)))
  927. }),
  928. ),
  929. ),
  930. )
  931. it.live("loads supported scalar and resource configuration", () =>
  932. Effect.acquireRelease(
  933. Effect.promise(() => tmpdir()),
  934. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  935. ).pipe(
  936. Effect.flatMap((tmp) =>
  937. Effect.gen(function* () {
  938. yield* Effect.promise(() =>
  939. fs.writeFile(
  940. path.join(tmp.path, "opencode.json"),
  941. JSON.stringify({
  942. shell: "/bin/bash",
  943. model: "anthropic/claude",
  944. default_agent: "reviewer",
  945. autoupdate: "notify",
  946. share: "disabled",
  947. enterprise: { url: "https://share.example.com" },
  948. username: "test-user",
  949. permissions: [
  950. { action: "bash", resource: "*", effect: "ask" },
  951. { action: "bash", resource: "git status", effect: "allow" },
  952. ],
  953. agents: {
  954. reviewer: {
  955. model: "openrouter/openai/gpt-5#high",
  956. request: {
  957. headers: { "x-agent": "reviewer" },
  958. body: { reasoningEffort: "high" },
  959. },
  960. description: "Review changes for correctness",
  961. system: "Find regressions.",
  962. mode: "subagent",
  963. hidden: false,
  964. color: "#ff6b6b",
  965. steps: 12,
  966. disabled: false,
  967. permissions: [{ action: "edit", resource: "*", effect: "deny" }],
  968. },
  969. },
  970. snapshots: false,
  971. watcher: { ignore: ["node_modules/**", "dist/**", ".git"] },
  972. formatter: {
  973. prettier: { disabled: true },
  974. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  975. },
  976. lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
  977. media: {
  978. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  979. },
  980. tool_output: { max_lines: 1000, max_bytes: 32768 },
  981. mcp: {
  982. timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
  983. servers: {
  984. local: {
  985. type: "local",
  986. command: ["node", "./mcp/server.js"],
  987. environment: { API_KEY: "secret" },
  988. disabled: false,
  989. codemode: false,
  990. timeout: { catalog: 10000 },
  991. },
  992. remote: {
  993. type: "remote",
  994. url: "https://mcp.example.com/mcp",
  995. headers: { Authorization: "Bearer token" },
  996. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  997. disabled: true,
  998. codemode: false,
  999. timeout: { startup: 15000 },
  1000. },
  1001. },
  1002. },
  1003. compaction: {
  1004. auto: true,
  1005. prune: false,
  1006. keep: { tokens: 2000 },
  1007. buffer: 10000,
  1008. },
  1009. skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
  1010. instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"],
  1011. references: {
  1012. local: { path: "../library" },
  1013. sdk: { repository: "github.com/example/sdk", branch: "main" },
  1014. shorthand: "github.com/example/docs",
  1015. },
  1016. plugins: [
  1017. "opencode-helicone-session",
  1018. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  1019. ],
  1020. }),
  1021. ),
  1022. )
  1023. return yield* Effect.gen(function* () {
  1024. const config = yield* Config.Service
  1025. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  1026. expect(documents).toHaveLength(1)
  1027. expect(documents[0]?.info.shell).toBe("/bin/bash")
  1028. expect(documents[0]?.info.model).toEqual(selection("anthropic/claude"))
  1029. expect(documents[0]?.info.default_agent).toBe("reviewer")
  1030. expect(documents[0]?.info.autoupdate).toBe("notify")
  1031. expect(documents[0]?.info.share).toBe("disabled")
  1032. expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
  1033. expect(documents[0]?.info.username).toBe("test-user")
  1034. expect(documents[0]?.info.permissions).toEqual([
  1035. { action: "bash", resource: "*", effect: "ask" },
  1036. { action: "bash", resource: "git status", effect: "allow" },
  1037. ])
  1038. const reviewer = documents[0]?.info.agents?.reviewer
  1039. expect(reviewer?.model).toEqual(selection("openrouter/openai/gpt-5#high"))
  1040. expect(reviewer?.request).toEqual({
  1041. headers: { "x-agent": "reviewer" },
  1042. body: { reasoningEffort: "high" },
  1043. })
  1044. expect(reviewer?.description).toBe("Review changes for correctness")
  1045. expect(reviewer?.system).toBe("Find regressions.")
  1046. expect(reviewer?.mode).toBe("subagent")
  1047. expect(reviewer?.hidden).toBe(false)
  1048. expect(reviewer?.color).toBe("#ff6b6b")
  1049. expect(reviewer?.steps).toBe(12)
  1050. expect(reviewer?.disabled).toBe(false)
  1051. expect(reviewer?.permissions).toEqual([{ action: "edit", resource: "*", effect: "deny" }])
  1052. expect(documents[0]?.info.snapshots).toBe(false)
  1053. expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
  1054. expect(documents[0]?.info.formatter).toEqual({
  1055. prettier: { disabled: true },
  1056. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  1057. })
  1058. expect(documents[0]?.info.lsp).toEqual({
  1059. typescript: { disabled: true },
  1060. custom: { command: ["custom-lsp"], extensions: [".foo"] },
  1061. })
  1062. expect(documents[0]?.info.media).toEqual({
  1063. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  1064. })
  1065. expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
  1066. expect(documents[0]?.info.mcp).toEqual({
  1067. timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
  1068. servers: {
  1069. local: {
  1070. type: "local",
  1071. command: ["node", "./mcp/server.js"],
  1072. environment: { API_KEY: "secret" },
  1073. disabled: false,
  1074. codemode: false,
  1075. timeout: { catalog: 10000 },
  1076. },
  1077. remote: {
  1078. type: "remote",
  1079. url: "https://mcp.example.com/mcp",
  1080. headers: { Authorization: "Bearer token" },
  1081. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  1082. disabled: true,
  1083. codemode: false,
  1084. timeout: { startup: 15000 },
  1085. },
  1086. },
  1087. })
  1088. expect(documents[0]?.info.compaction).toEqual({
  1089. auto: true,
  1090. keep: { tokens: 2000 },
  1091. buffer: 10000,
  1092. })
  1093. expect(documents[0]?.info.skills).toEqual([
  1094. "./skills",
  1095. "~/shared-skills",
  1096. "https://example.com/.well-known/skills/",
  1097. ])
  1098. expect(documents[0]?.info.instructions).toEqual([
  1099. "CONTRIBUTING.md",
  1100. ".cursor/rules/*.md",
  1101. "https://example.com/shared-rules.md",
  1102. ])
  1103. expect(documents[0]?.info.references).toEqual({
  1104. local: { path: "../library" },
  1105. sdk: { repository: "github.com/example/sdk", branch: "main" },
  1106. shorthand: "github.com/example/docs",
  1107. })
  1108. expect(documents[0]?.info.plugins).toEqual([
  1109. "opencode-helicone-session",
  1110. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  1111. ])
  1112. }).pipe(Effect.provide(testLayer(tmp.path)))
  1113. }),
  1114. ),
  1115. ),
  1116. )
  1117. it.live("migrates the deprecated reference key into references", () =>
  1118. Effect.acquireRelease(
  1119. Effect.promise(() => tmpdir()),
  1120. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1121. ).pipe(
  1122. Effect.flatMap((tmp) =>
  1123. Effect.gen(function* () {
  1124. yield* Effect.promise(() =>
  1125. fs.writeFile(
  1126. path.join(tmp.path, "opencode.json"),
  1127. JSON.stringify({
  1128. reference: {
  1129. local: { path: "../library" },
  1130. sdk: { repository: "github.com/example/sdk", branch: "main" },
  1131. shorthand: "github.com/example/docs",
  1132. },
  1133. }),
  1134. ),
  1135. )
  1136. return yield* Effect.gen(function* () {
  1137. const config = yield* Config.Service
  1138. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  1139. expect(documents).toHaveLength(1)
  1140. expect(documents[0]?.info.references).toEqual({
  1141. local: { path: "../library" },
  1142. sdk: { repository: "github.com/example/sdk", branch: "main" },
  1143. shorthand: "github.com/example/docs",
  1144. })
  1145. }).pipe(Effect.provide(testLayer(tmp.path)))
  1146. }),
  1147. ),
  1148. ),
  1149. )
  1150. it.live("migrates v1 configuration when a v1-only key is present", () =>
  1151. Effect.acquireRelease(
  1152. Effect.promise(() => tmpdir()),
  1153. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1154. ).pipe(
  1155. Effect.flatMap((tmp) =>
  1156. Effect.gen(function* () {
  1157. yield* Effect.promise(() =>
  1158. fs.writeFile(
  1159. path.join(tmp.path, "opencode.json"),
  1160. JSON.stringify({
  1161. shell: "/bin/zsh",
  1162. default_agent: "reviewer",
  1163. snapshot: false,
  1164. autoshare: true,
  1165. permission: {
  1166. bash: "ask",
  1167. edit: { "*.md": "allow", "*": "deny" },
  1168. question: "deny",
  1169. },
  1170. agent: {
  1171. reviewer: {
  1172. prompt: "Review changes.",
  1173. disable: true,
  1174. temperature: 0.2,
  1175. permission: { read: "allow" },
  1176. },
  1177. },
  1178. plugin: [
  1179. "opencode-helicone-session",
  1180. ["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }],
  1181. ],
  1182. skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
  1183. references: {
  1184. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  1185. },
  1186. attachment: { image: { auto_resize: false, max_width: 1200 } },
  1187. provider: {
  1188. custom: {
  1189. options: { apiKey: "secret" },
  1190. models: {
  1191. model: {
  1192. options: { reasoningEffort: "high" },
  1193. variants: { fast: { temperature: 0.2 } },
  1194. },
  1195. },
  1196. },
  1197. openai: {
  1198. npm: "@ai-sdk/openai",
  1199. options: { apiKey: "secret", organization: "org" },
  1200. models: {
  1201. model: {
  1202. options: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  1203. variants: { high: { reasoningEffort: "high", reasoningSummary: "auto" } },
  1204. },
  1205. },
  1206. },
  1207. anthropic: {
  1208. npm: "@ai-sdk/anthropic",
  1209. models: {
  1210. model: {
  1211. options: {
  1212. effort: "high",
  1213. taskBudget: 4096,
  1214. metadata: { userId: "user-1" },
  1215. },
  1216. },
  1217. },
  1218. },
  1219. },
  1220. compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 },
  1221. experimental: { mcp_timeout: 5000 },
  1222. mcp: {
  1223. local: { type: "local", command: ["node", "server.js"], enabled: false, timeout: 10000 },
  1224. remote: {
  1225. type: "remote",
  1226. url: "https://mcp.example.com",
  1227. oauth: { clientId: "client", callbackPort: 19876 },
  1228. timeout: 20000,
  1229. },
  1230. },
  1231. }),
  1232. ),
  1233. )
  1234. return yield* Effect.gen(function* () {
  1235. const config = yield* Config.Service
  1236. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  1237. expect(documents).toHaveLength(1)
  1238. expect(documents[0]?.info).toBeInstanceOf(Info)
  1239. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  1240. expect(documents[0]?.info.default_agent).toBe("reviewer")
  1241. expect(documents[0]?.info.snapshots).toBe(false)
  1242. expect(documents[0]?.info.share).toBe("auto")
  1243. expect(documents[0]?.info.permissions).toEqual([
  1244. { action: "shell", resource: "*", effect: "ask" },
  1245. { action: "edit", resource: "*.md", effect: "allow" },
  1246. { action: "edit", resource: "*", effect: "deny" },
  1247. { action: "question", resource: "*", effect: "deny" },
  1248. ])
  1249. expect(documents[0]?.info.agents?.reviewer).toMatchObject({
  1250. system: "Review changes.",
  1251. disabled: true,
  1252. request: { body: { temperature: 0.2 } },
  1253. permissions: [{ action: "read", resource: "*", effect: "allow" }],
  1254. })
  1255. expect(documents[0]?.info.plugins).toEqual([
  1256. "opencode-helicone-session",
  1257. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  1258. ])
  1259. expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
  1260. expect(documents[0]?.info.references).toEqual({
  1261. docs: { path: "../docs", description: "Use for product documentation", hidden: true },
  1262. })
  1263. expect(documents[0]?.info.media).toEqual({ image: { auto_resize: false, max_width: 1200 } })
  1264. expect(documents[0]?.info.providers?.custom).toMatchObject({
  1265. settings: { apiKey: "secret" },
  1266. models: {
  1267. model: {
  1268. settings: { reasoningEffort: "high" },
  1269. variants: [{ id: "fast", settings: { temperature: 0.2 } }],
  1270. },
  1271. },
  1272. })
  1273. expect(documents[0]?.info.providers?.openai).toMatchObject({
  1274. package: Provider.aisdk("@ai-sdk/openai"),
  1275. settings: { apiKey: "secret", organization: "org" },
  1276. models: {
  1277. model: {
  1278. settings: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  1279. variants: [{ id: "high", settings: { reasoningEffort: "high", reasoningSummary: "auto" } }],
  1280. },
  1281. },
  1282. })
  1283. expect(documents[0]?.info.providers?.anthropic).toMatchObject({
  1284. package: Provider.aisdk("@ai-sdk/anthropic"),
  1285. models: {
  1286. model: {
  1287. settings: {
  1288. effort: "high",
  1289. taskBudget: 4096,
  1290. metadata: { userId: "user-1" },
  1291. },
  1292. },
  1293. },
  1294. })
  1295. expect(documents[0]?.info.compaction).toEqual({
  1296. auto: true,
  1297. keep: { tokens: 2000 },
  1298. buffer: 10000,
  1299. })
  1300. expect(documents[0]?.info.mcp).toMatchObject({
  1301. timeout: { catalog: 5000, execution: 5000 },
  1302. servers: {
  1303. local: {
  1304. type: "local",
  1305. command: ["node", "server.js"],
  1306. disabled: true,
  1307. timeout: { catalog: 10000, execution: 10000 },
  1308. },
  1309. remote: {
  1310. type: "remote",
  1311. url: "https://mcp.example.com",
  1312. oauth: { client_id: "client", callback_port: 19876 },
  1313. timeout: { catalog: 20000, execution: 20000 },
  1314. },
  1315. },
  1316. })
  1317. }).pipe(Effect.provide(testLayer(tmp.path)))
  1318. }),
  1319. ),
  1320. ),
  1321. )
  1322. it.live("ignores an invalid file while loading valid config values", () =>
  1323. Effect.acquireRelease(
  1324. Effect.promise(() => tmpdir()),
  1325. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1326. ).pipe(
  1327. Effect.flatMap((tmp) =>
  1328. Effect.gen(function* () {
  1329. yield* Effect.promise(() =>
  1330. Promise.all([
  1331. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "base" })),
  1332. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{ invalid"),
  1333. ]),
  1334. )
  1335. return yield* Effect.gen(function* () {
  1336. const config = yield* Config.Service
  1337. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  1338. expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
  1339. }).pipe(Effect.provide(testLayer(tmp.path)))
  1340. }),
  1341. ),
  1342. ),
  1343. )
  1344. it.live("loads global and ancestor configuration across the project boundary", () =>
  1345. Effect.acquireRelease(
  1346. Effect.promise(() => tmpdir()),
  1347. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  1348. ).pipe(
  1349. Effect.flatMap((tmp) => {
  1350. const global = path.join(tmp.path, "global")
  1351. const root = path.join(tmp.path, "repo")
  1352. const parent = path.join(root, "packages")
  1353. const directory = path.join(parent, "app")
  1354. const globalAgents = path.join(global, "home", ".agents")
  1355. const globalClaude = path.join(global, "home", ".claude")
  1356. return Effect.gen(function* () {
  1357. yield* Effect.promise(async () => {
  1358. await fs.mkdir(global, { recursive: true })
  1359. await fs.mkdir(globalAgents, { recursive: true })
  1360. await fs.mkdir(globalClaude, { recursive: true })
  1361. await fs.mkdir(directory, { recursive: true })
  1362. await fs.mkdir(path.join(root, ".agents"), { recursive: true })
  1363. await fs.mkdir(path.join(root, ".claude"), { recursive: true })
  1364. await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
  1365. await fs.mkdir(path.join(directory, ".agents"), { recursive: true })
  1366. await fs.mkdir(path.join(directory, ".claude"), { recursive: true })
  1367. await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
  1368. await Promise.all([
  1369. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
  1370. fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
  1371. fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
  1372. fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
  1373. fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ $schema: "directory" })),
  1374. fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
  1375. fs.writeFile(
  1376. path.join(directory, ".opencode", "opencode.jsonc"),
  1377. JSON.stringify({ $schema: "directory-dot" }),
  1378. ),
  1379. ])
  1380. })
  1381. return yield* Effect.gen(function* () {
  1382. const config = yield* Config.Service
  1383. const entries = yield* config.entries()
  1384. const documents = entries.filter((entry) => entry.type === "document")
  1385. expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
  1386. AbsolutePath.make(global),
  1387. AbsolutePath.make(path.join(root, ".opencode")),
  1388. AbsolutePath.make(path.join(directory, ".opencode")),
  1389. ])
  1390. expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
  1391. AbsolutePath.make(globalAgents),
  1392. AbsolutePath.make(path.join(directory, ".agents")),
  1393. AbsolutePath.make(path.join(root, ".agents")),
  1394. ])
  1395. expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
  1396. AbsolutePath.make(globalClaude),
  1397. AbsolutePath.make(path.join(directory, ".claude")),
  1398. AbsolutePath.make(path.join(root, ".claude")),
  1399. ])
  1400. expect(documents.map((document) => document.info.$schema)).toEqual([
  1401. "global",
  1402. "outside",
  1403. "root",
  1404. "parent",
  1405. "directory",
  1406. "root-dot",
  1407. "directory-dot",
  1408. ])
  1409. expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
  1410. AbsolutePath.make(globalClaude),
  1411. AbsolutePath.make(path.join(directory, ".claude")),
  1412. AbsolutePath.make(path.join(root, ".claude")),
  1413. AbsolutePath.make(globalAgents),
  1414. AbsolutePath.make(path.join(directory, ".agents")),
  1415. AbsolutePath.make(path.join(root, ".agents")),
  1416. "global",
  1417. AbsolutePath.make(global),
  1418. "outside",
  1419. AbsolutePath.make(path.join(tmp.path, "opencode.json")),
  1420. "root",
  1421. AbsolutePath.make(path.join(root, "opencode.json")),
  1422. "parent",
  1423. AbsolutePath.make(path.join(parent, "opencode.jsonc")),
  1424. "directory",
  1425. AbsolutePath.make(path.join(directory, "opencode.json")),
  1426. "root-dot",
  1427. AbsolutePath.make(path.join(root, ".opencode")),
  1428. "directory-dot",
  1429. AbsolutePath.make(path.join(directory, ".opencode")),
  1430. ])
  1431. }).pipe(
  1432. Effect.provide(
  1433. testLayer(directory, global, root, {
  1434. type: "git",
  1435. store: AbsolutePath.make(path.join(root, ".git")),
  1436. }),
  1437. ),
  1438. )
  1439. })
  1440. }),
  1441. ),
  1442. )
  1443. })