config.test.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731
  1. import path from "path"
  2. import fs from "fs/promises"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Layer, Schema } from "effect"
  5. import { FastCheck } from "effect/testing"
  6. import { Config } from "@opencode-ai/core/config"
  7. import { ConfigProvider } from "@opencode-ai/core/config/provider"
  8. import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
  9. import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
  10. import { FSUtil } from "@opencode-ai/core/fs-util"
  11. import { Global } from "@opencode-ai/core/global"
  12. import { Location } from "@opencode-ai/core/location"
  13. import { Policy } from "@opencode-ai/core/policy"
  14. import { Project } from "@opencode-ai/core/project"
  15. import { AbsolutePath } from "@opencode-ai/core/schema"
  16. import { location } from "../fixture/location"
  17. import { tmpdir } from "../fixture/tmpdir"
  18. import { testEffect } from "../lib/effect"
  19. const it = testEffect(Layer.empty)
  20. function testLayer(
  21. directory: string,
  22. globalDirectory = path.join(directory, "global"),
  23. projectDirectory = directory,
  24. vcs?: Project.Vcs,
  25. ) {
  26. return Config.locationLayer.pipe(
  27. Layer.provide(FSUtil.defaultLayer),
  28. Layer.provide(Global.layerWith({ config: globalDirectory })),
  29. Layer.provide(
  30. Layer.succeed(
  31. Location.Service,
  32. Location.Service.of(
  33. location(
  34. { directory: AbsolutePath.make(directory) },
  35. { projectDirectory: AbsolutePath.make(projectDirectory), vcs },
  36. ),
  37. ),
  38. ),
  39. ),
  40. )
  41. }
  42. const provider = {
  43. api: { type: "native", settings: {} },
  44. request: {
  45. headers: {},
  46. body: {},
  47. },
  48. models: {},
  49. }
  50. describe("Config", () => {
  51. it.effect("returns the latest defined scalar from priority-ordered documents", () =>
  52. Effect.sync(() => {
  53. const entries = [
  54. new Config.Document({ type: "document", info: new Config.Info({ model: "openrouter/openai/gpt-5" }) }),
  55. new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
  56. new Config.Document({ type: "document", info: new Config.Info({}) }),
  57. new Config.Document({ type: "document", info: new Config.Info({ model: "openrouter/openai/gpt-5.5" }) }),
  58. ]
  59. expect(Config.latest(entries, "model")).toBe("openrouter/openai/gpt-5.5")
  60. expect(Config.latest(entries, "default_agent")).toBeUndefined()
  61. }),
  62. )
  63. it.effect("detects v1 configuration from any v1-only top-level key", () =>
  64. Effect.sync(() => {
  65. expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
  66. expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
  67. expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
  68. }),
  69. )
  70. it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
  71. Effect.sync(() => {
  72. FastCheck.assert(
  73. FastCheck.property(Schema.toArbitrary(ConfigV1.Info), (info) => {
  74. Schema.decodeUnknownSync(Config.Info)(ConfigMigrateV1.migrate(info), { errors: "all" })
  75. }),
  76. { numRuns: 100 },
  77. )
  78. }),
  79. )
  80. it.effect("migrates v1 provider setup options into AISDK settings", () =>
  81. Effect.sync(() => {
  82. const migrated = ConfigMigrateV1.migrate({
  83. provider: {
  84. bedrock: {
  85. npm: "@ai-sdk/amazon-bedrock",
  86. options: {
  87. headers: { "x-test": "1" },
  88. body: { trace: true },
  89. region: "us-east-1",
  90. profile: "dev",
  91. },
  92. },
  93. },
  94. })
  95. expect(migrated.providers?.bedrock?.api).toEqual({
  96. type: "aisdk",
  97. package: "@ai-sdk/amazon-bedrock",
  98. url: undefined,
  99. settings: { region: "us-east-1", profile: "dev" },
  100. })
  101. expect(migrated.providers?.bedrock?.request).toEqual({
  102. headers: { "x-test": "1" },
  103. body: { trace: true },
  104. })
  105. }),
  106. )
  107. it.effect("migrates v1 command configuration", () =>
  108. Effect.sync(() => {
  109. expect(
  110. ConfigMigrateV1.migrate({
  111. command: {
  112. review: {
  113. template: "Review changes",
  114. description: "Review code",
  115. agent: "reviewer",
  116. model: "anthropic/claude",
  117. variant: "high",
  118. subtask: true,
  119. },
  120. },
  121. }).commands,
  122. ).toEqual({
  123. review: {
  124. template: "Review changes",
  125. description: "Review code",
  126. agent: "reviewer",
  127. model: "anthropic/claude",
  128. variant: "high",
  129. subtask: true,
  130. },
  131. })
  132. }),
  133. )
  134. it.live("returns an empty configuration when directory files do not exist", () =>
  135. Effect.acquireRelease(
  136. Effect.promise(() => tmpdir()),
  137. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  138. ).pipe(
  139. Effect.flatMap((tmp) =>
  140. Effect.gen(function* () {
  141. const config = yield* Config.Service
  142. const entries = yield* config.entries()
  143. expect(entries).toEqual([
  144. new Config.Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
  145. ])
  146. }).pipe(Effect.provide(testLayer(tmp.path))),
  147. ),
  148. ),
  149. )
  150. it.live("loads JSON and JSONC files from lowest to highest priority", () =>
  151. Effect.acquireRelease(
  152. Effect.promise(() => tmpdir()),
  153. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  154. ).pipe(
  155. Effect.flatMap((tmp) =>
  156. Effect.gen(function* () {
  157. yield* Effect.promise(() =>
  158. Promise.all([
  159. fs.writeFile(
  160. path.join(tmp.path, "config.json"),
  161. JSON.stringify({ $schema: "base", providers: { base: provider } }),
  162. ),
  163. fs.writeFile(
  164. path.join(tmp.path, "opencode.json"),
  165. JSON.stringify({ $schema: "middle", providers: { middle: provider } }),
  166. ),
  167. fs.writeFile(
  168. path.join(tmp.path, "opencode.jsonc"),
  169. `{
  170. // Later global files override scalar fields while retaining providers.
  171. "$schema": "last",
  172. "providers": { "last": ${JSON.stringify(provider)} },
  173. }`,
  174. ),
  175. ]),
  176. )
  177. return yield* Effect.gen(function* () {
  178. const config = yield* Config.Service
  179. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  180. expect(documents).toHaveLength(3)
  181. expect(documents.map((document) => document.type)).toEqual(["document", "document", "document"])
  182. expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
  183. expect(documents[0]).toBeInstanceOf(Config.Document)
  184. expect(documents[0]?.path).toBe(path.join(tmp.path, "config.json"))
  185. expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
  186. yield* Effect.promise(() =>
  187. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
  188. )
  189. expect(
  190. (yield* config.entries())
  191. .filter((entry) => entry.type === "document")
  192. .map((document) => document.info.$schema),
  193. ).toEqual(["base", "middle", "last"])
  194. }).pipe(Effect.provide(testLayer(tmp.path)))
  195. }),
  196. ),
  197. ),
  198. )
  199. it.live("accepts $schema metadata without writing it into config files", () =>
  200. Effect.acquireRelease(
  201. Effect.promise(() => tmpdir()),
  202. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  203. ).pipe(
  204. Effect.flatMap((tmp) =>
  205. Effect.gen(function* () {
  206. const file = path.join(tmp.path, "opencode.json")
  207. const contents = JSON.stringify({
  208. shell: "/bin/zsh",
  209. experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
  210. providers: { local: provider },
  211. })
  212. yield* Effect.promise(() => fs.writeFile(file, contents))
  213. return yield* Effect.gen(function* () {
  214. const config = yield* Config.Service
  215. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  216. expect(documents[0]?.info.$schema).toBeUndefined()
  217. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  218. expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({
  219. effect: "deny",
  220. action: "provider.use",
  221. resource: "openai",
  222. })
  223. expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
  224. }).pipe(Effect.provide(testLayer(tmp.path)))
  225. }),
  226. ),
  227. ),
  228. )
  229. it.live("loads supported scalar and resource configuration", () =>
  230. Effect.acquireRelease(
  231. Effect.promise(() => tmpdir()),
  232. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  233. ).pipe(
  234. Effect.flatMap((tmp) =>
  235. Effect.gen(function* () {
  236. yield* Effect.promise(() =>
  237. fs.writeFile(
  238. path.join(tmp.path, "opencode.json"),
  239. JSON.stringify({
  240. shell: "/bin/bash",
  241. model: "anthropic/claude",
  242. default_agent: "reviewer",
  243. autoupdate: "notify",
  244. share: "disabled",
  245. enterprise: { url: "https://share.example.com" },
  246. username: "test-user",
  247. permissions: [
  248. { action: "bash", resource: "*", effect: "ask" },
  249. { action: "bash", resource: "git status", effect: "allow" },
  250. ],
  251. agents: {
  252. reviewer: {
  253. model: "openrouter/openai/gpt-5",
  254. variant: "high",
  255. request: {
  256. headers: { "x-agent": "reviewer" },
  257. body: { reasoningEffort: "high" },
  258. },
  259. description: "Review changes for correctness",
  260. system: "Find regressions.",
  261. mode: "subagent",
  262. hidden: false,
  263. color: "warning",
  264. steps: 12,
  265. disabled: false,
  266. permissions: [{ action: "edit", resource: "*", effect: "deny" }],
  267. },
  268. },
  269. snapshots: false,
  270. watcher: { ignore: ["node_modules/**", "dist/**", ".git"] },
  271. formatter: {
  272. prettier: { disabled: true },
  273. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  274. },
  275. lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
  276. attachments: {
  277. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  278. },
  279. tool_output: { max_lines: 1000, max_bytes: 32768 },
  280. mcp: {
  281. timeout: 5000,
  282. servers: {
  283. local: {
  284. type: "local",
  285. command: ["node", "./mcp/server.js"],
  286. environment: { API_KEY: "secret" },
  287. disabled: false,
  288. timeout: 10000,
  289. },
  290. remote: {
  291. type: "remote",
  292. url: "https://mcp.example.com/mcp",
  293. headers: { Authorization: "Bearer token" },
  294. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  295. disabled: true,
  296. },
  297. },
  298. },
  299. compaction: {
  300. auto: true,
  301. prune: false,
  302. keep: { tokens: 2000 },
  303. buffer: 10000,
  304. },
  305. skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
  306. instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"],
  307. references: {
  308. local: { path: "../library" },
  309. sdk: { repository: "github.com/example/sdk", branch: "main" },
  310. shorthand: "github.com/example/docs",
  311. },
  312. plugins: [
  313. "opencode-helicone-session",
  314. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  315. ],
  316. }),
  317. ),
  318. )
  319. return yield* Effect.gen(function* () {
  320. const config = yield* Config.Service
  321. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  322. expect(documents).toHaveLength(1)
  323. expect(documents[0]?.info.shell).toBe("/bin/bash")
  324. expect(documents[0]?.info.model).toBe("anthropic/claude")
  325. expect(documents[0]?.info.default_agent).toBe("reviewer")
  326. expect(documents[0]?.info.autoupdate).toBe("notify")
  327. expect(documents[0]?.info.share).toBe("disabled")
  328. expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
  329. expect(documents[0]?.info.username).toBe("test-user")
  330. expect(documents[0]?.info.permissions).toEqual([
  331. { action: "bash", resource: "*", effect: "ask" },
  332. { action: "bash", resource: "git status", effect: "allow" },
  333. ])
  334. const reviewer = documents[0]?.info.agents?.reviewer
  335. expect(reviewer?.model).toBe("openrouter/openai/gpt-5")
  336. expect(reviewer?.variant).toBe("high")
  337. expect(reviewer?.request).toEqual({
  338. headers: { "x-agent": "reviewer" },
  339. body: { reasoningEffort: "high" },
  340. })
  341. expect(reviewer?.description).toBe("Review changes for correctness")
  342. expect(reviewer?.system).toBe("Find regressions.")
  343. expect(reviewer?.mode).toBe("subagent")
  344. expect(reviewer?.hidden).toBe(false)
  345. expect(reviewer?.color).toBe("warning")
  346. expect(reviewer?.steps).toBe(12)
  347. expect(reviewer?.disabled).toBe(false)
  348. expect(reviewer?.permissions).toEqual([{ action: "edit", resource: "*", effect: "deny" }])
  349. expect(documents[0]?.info.snapshots).toBe(false)
  350. expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
  351. expect(documents[0]?.info.formatter).toEqual({
  352. prettier: { disabled: true },
  353. custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
  354. })
  355. expect(documents[0]?.info.lsp).toEqual({
  356. typescript: { disabled: true },
  357. custom: { command: ["custom-lsp"], extensions: [".foo"] },
  358. })
  359. expect(documents[0]?.info.attachments).toEqual({
  360. image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
  361. })
  362. expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
  363. expect(documents[0]?.info.mcp).toEqual({
  364. timeout: 5000,
  365. servers: {
  366. local: {
  367. type: "local",
  368. command: ["node", "./mcp/server.js"],
  369. environment: { API_KEY: "secret" },
  370. disabled: false,
  371. timeout: 10000,
  372. },
  373. remote: {
  374. type: "remote",
  375. url: "https://mcp.example.com/mcp",
  376. headers: { Authorization: "Bearer token" },
  377. oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
  378. disabled: true,
  379. },
  380. },
  381. })
  382. expect(documents[0]?.info.compaction).toEqual({
  383. auto: true,
  384. prune: false,
  385. keep: { tokens: 2000 },
  386. buffer: 10000,
  387. })
  388. expect(documents[0]?.info.skills).toEqual([
  389. "./skills",
  390. "~/shared-skills",
  391. "https://example.com/.well-known/skills/",
  392. ])
  393. expect(documents[0]?.info.instructions).toEqual([
  394. "CONTRIBUTING.md",
  395. ".cursor/rules/*.md",
  396. "https://example.com/shared-rules.md",
  397. ])
  398. expect(documents[0]?.info.references).toEqual({
  399. local: { path: "../library" },
  400. sdk: { repository: "github.com/example/sdk", branch: "main" },
  401. shorthand: "github.com/example/docs",
  402. })
  403. expect(documents[0]?.info.plugins).toEqual([
  404. "opencode-helicone-session",
  405. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  406. ])
  407. }).pipe(Effect.provide(testLayer(tmp.path)))
  408. }),
  409. ),
  410. ),
  411. )
  412. it.live("migrates v1 configuration when a v1-only key is present", () =>
  413. Effect.acquireRelease(
  414. Effect.promise(() => tmpdir()),
  415. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  416. ).pipe(
  417. Effect.flatMap((tmp) =>
  418. Effect.gen(function* () {
  419. yield* Effect.promise(() =>
  420. fs.writeFile(
  421. path.join(tmp.path, "opencode.json"),
  422. JSON.stringify({
  423. shell: "/bin/zsh",
  424. default_agent: "reviewer",
  425. snapshot: false,
  426. autoshare: true,
  427. permission: {
  428. bash: "ask",
  429. edit: { "*.md": "allow", "*": "deny" },
  430. question: "deny",
  431. },
  432. agent: {
  433. reviewer: {
  434. prompt: "Review changes.",
  435. disable: true,
  436. temperature: 0.2,
  437. permission: { read: "allow" },
  438. },
  439. },
  440. plugin: [
  441. "opencode-helicone-session",
  442. ["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }],
  443. ],
  444. skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
  445. reference: { docs: { path: "../docs" } },
  446. attachment: { image: { auto_resize: false, max_width: 1200 } },
  447. provider: {
  448. custom: {
  449. options: { apiKey: "secret" },
  450. models: {
  451. model: {
  452. options: { reasoningEffort: "high" },
  453. variants: { fast: { temperature: 0.2 } },
  454. },
  455. },
  456. },
  457. openai: {
  458. npm: "@ai-sdk/openai",
  459. options: { apiKey: "secret", organization: "org" },
  460. models: {
  461. model: {
  462. options: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  463. variants: { high: { reasoningEffort: "high", reasoningSummary: "auto" } },
  464. },
  465. },
  466. },
  467. anthropic: {
  468. npm: "@ai-sdk/anthropic",
  469. models: {
  470. model: {
  471. options: {
  472. effort: "high",
  473. taskBudget: 4096,
  474. metadata: { userId: "user-1" },
  475. },
  476. },
  477. },
  478. },
  479. },
  480. compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 },
  481. experimental: { mcp_timeout: 5000 },
  482. mcp: {
  483. local: { type: "local", command: ["node", "server.js"], enabled: false },
  484. remote: {
  485. type: "remote",
  486. url: "https://mcp.example.com",
  487. oauth: { clientId: "client", callbackPort: 19876 },
  488. },
  489. },
  490. }),
  491. ),
  492. )
  493. return yield* Effect.gen(function* () {
  494. const config = yield* Config.Service
  495. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  496. expect(documents).toHaveLength(1)
  497. expect(documents[0]?.info).toBeInstanceOf(Config.Info)
  498. expect(documents[0]?.info.shell).toBe("/bin/zsh")
  499. expect(documents[0]?.info.default_agent).toBe("reviewer")
  500. expect(documents[0]?.info.snapshots).toBe(false)
  501. expect(documents[0]?.info.share).toBe("auto")
  502. expect(documents[0]?.info.permissions).toEqual([
  503. { action: "bash", resource: "*", effect: "ask" },
  504. { action: "edit", resource: "*.md", effect: "allow" },
  505. { action: "edit", resource: "*", effect: "deny" },
  506. { action: "question", resource: "*", effect: "deny" },
  507. ])
  508. expect(documents[0]?.info.agents?.reviewer).toMatchObject({
  509. system: "Review changes.",
  510. disabled: true,
  511. request: { body: { temperature: 0.2 } },
  512. permissions: [{ action: "read", resource: "*", effect: "allow" }],
  513. })
  514. expect(documents[0]?.info.plugins).toEqual([
  515. "opencode-helicone-session",
  516. { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
  517. ])
  518. expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
  519. expect(documents[0]?.info.references).toEqual({ docs: { path: "../docs" } })
  520. expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
  521. expect(documents[0]?.info.providers?.custom).toMatchObject({
  522. request: { body: { apiKey: "secret" } },
  523. models: {
  524. model: {
  525. request: { body: { reasoningEffort: "high" } },
  526. variants: [{ id: "fast", body: { temperature: 0.2 } }],
  527. },
  528. },
  529. })
  530. expect(documents[0]?.info.providers?.openai).toMatchObject({
  531. api: { settings: {} },
  532. request: { headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" } },
  533. models: {
  534. model: {
  535. request: {
  536. body: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
  537. },
  538. variants: [{ id: "high", body: { reasoningEffort: "high", reasoningSummary: "auto" } }],
  539. },
  540. },
  541. })
  542. expect(documents[0]?.info.providers?.anthropic).toMatchObject({
  543. models: {
  544. model: {
  545. request: {
  546. body: {
  547. output_config: { effort: "high", task_budget: 4096 },
  548. metadata: { user_id: "user-1" },
  549. },
  550. },
  551. },
  552. },
  553. })
  554. expect(documents[0]?.info.compaction).toEqual({
  555. auto: true,
  556. prune: undefined,
  557. keep: { tokens: 2000 },
  558. buffer: 10000,
  559. })
  560. expect(documents[0]?.info.mcp).toMatchObject({
  561. timeout: 5000,
  562. servers: {
  563. local: { type: "local", command: ["node", "server.js"], disabled: true },
  564. remote: {
  565. type: "remote",
  566. url: "https://mcp.example.com",
  567. oauth: { client_id: "client", callback_port: 19876 },
  568. },
  569. },
  570. })
  571. }).pipe(Effect.provide(testLayer(tmp.path)))
  572. }),
  573. ),
  574. ),
  575. )
  576. it.live("ignores invalid files while loading valid config values", () =>
  577. Effect.acquireRelease(
  578. Effect.promise(() => tmpdir()),
  579. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  580. ).pipe(
  581. Effect.flatMap((tmp) =>
  582. Effect.gen(function* () {
  583. yield* Effect.promise(() =>
  584. Promise.all([
  585. fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })),
  586. fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"),
  587. fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })),
  588. ]),
  589. )
  590. return yield* Effect.gen(function* () {
  591. const config = yield* Config.Service
  592. const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
  593. expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
  594. }).pipe(Effect.provide(testLayer(tmp.path)))
  595. }),
  596. ),
  597. ),
  598. )
  599. it.live("loads policy statements in reverse config order", () =>
  600. Effect.acquireRelease(
  601. Effect.promise(() => tmpdir()),
  602. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  603. ).pipe(
  604. Effect.flatMap((tmp) => {
  605. const global = path.join(tmp.path, "global")
  606. return Effect.gen(function* () {
  607. yield* Effect.promise(async () => {
  608. await fs.mkdir(global, { recursive: true })
  609. await fs.writeFile(
  610. path.join(global, "opencode.json"),
  611. JSON.stringify({
  612. experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
  613. }),
  614. )
  615. await fs.writeFile(
  616. path.join(tmp.path, "opencode.json"),
  617. JSON.stringify({
  618. experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] },
  619. }),
  620. )
  621. })
  622. return yield* Effect.gen(function* () {
  623. const policy = yield* Policy.Service
  624. expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
  625. }).pipe(Effect.provide(testLayer(tmp.path, global)))
  626. })
  627. }),
  628. ),
  629. )
  630. it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
  631. Effect.acquireRelease(
  632. Effect.promise(() => tmpdir()),
  633. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  634. ).pipe(
  635. Effect.flatMap((tmp) => {
  636. const global = path.join(tmp.path, "global")
  637. const root = path.join(tmp.path, "repo")
  638. const parent = path.join(root, "packages")
  639. const directory = path.join(parent, "app")
  640. return Effect.gen(function* () {
  641. yield* Effect.promise(async () => {
  642. await fs.mkdir(global, { recursive: true })
  643. await fs.mkdir(directory, { recursive: true })
  644. await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
  645. await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
  646. await Promise.all([
  647. fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
  648. fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
  649. fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
  650. fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
  651. fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })),
  652. fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
  653. fs.writeFile(
  654. path.join(directory, ".opencode", "opencode.jsonc"),
  655. JSON.stringify({ $schema: "directory-dot" }),
  656. ),
  657. ])
  658. })
  659. return yield* Effect.gen(function* () {
  660. const config = yield* Config.Service
  661. const entries = yield* config.entries()
  662. const documents = entries.filter((entry) => entry.type === "document")
  663. expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
  664. AbsolutePath.make(global),
  665. AbsolutePath.make(path.join(root, ".opencode")),
  666. AbsolutePath.make(path.join(directory, ".opencode")),
  667. ])
  668. expect(documents.map((document) => document.info.$schema)).toEqual([
  669. "global",
  670. "root",
  671. "parent",
  672. "directory",
  673. "root-dot",
  674. "directory-dot",
  675. ])
  676. expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
  677. "global",
  678. AbsolutePath.make(global),
  679. "root",
  680. "parent",
  681. "directory",
  682. "root-dot",
  683. AbsolutePath.make(path.join(root, ".opencode")),
  684. "directory-dot",
  685. AbsolutePath.make(path.join(directory, ".opencode")),
  686. ])
  687. }).pipe(
  688. Effect.provide(
  689. testLayer(directory, global, root, {
  690. type: "git",
  691. store: AbsolutePath.make(path.join(root, ".git")),
  692. }),
  693. ),
  694. )
  695. })
  696. }),
  697. ),
  698. )
  699. })