agent.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. import { describe, expect } from "bun:test"
  2. import fs from "fs/promises"
  3. import path from "path"
  4. import { Effect, Schema } from "effect"
  5. import { AgentV2 } from "@opencode-ai/core/agent"
  6. import { Config } from "@opencode-ai/core/config"
  7. import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
  8. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  9. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  10. import { FSUtil } from "@opencode-ai/core/fs-util"
  11. import { Global } from "@opencode-ai/core/global"
  12. import { PermissionV2 } from "@opencode-ai/core/permission"
  13. import { AbsolutePath } from "@opencode-ai/core/schema"
  14. import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
  15. import { tmpdir } from "../fixture/tmpdir"
  16. import { testEffect } from "../lib/effect"
  17. import { agentHost, host } from "../plugin/host"
  18. const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node, Global.node])))
  19. const decode = Schema.decodeUnknownSync(Config.Info)
  20. describe("ConfigAgentPlugin.Plugin", () => {
  21. it.effect("matches POSIX paths against home-relative permissions", () =>
  22. Effect.gen(function* () {
  23. const permissions = yield* loadHomePermissions("/home/test")
  24. expect(PermissionV2.evaluate("external_directory", "/home/test/p/opencode/src/*", permissions).effect).toBe(
  25. "allow",
  26. )
  27. expect(PermissionV2.evaluate("external_directory", "/home/test/cache/files/*", permissions).effect).toBe("deny")
  28. expect(PermissionV2.evaluate("external_directory", "/some/~/path", permissions).effect).toBe("deny")
  29. expect(PermissionV2.evaluate("external_directory", "$HOMELESS/private/*", permissions).effect).toBe("deny")
  30. expect(PermissionV2.evaluate("bash", "$HOME/private/key", permissions).effect).toBe("deny")
  31. }),
  32. )
  33. it.effect("matches Windows paths against home-relative permissions", () =>
  34. Effect.gen(function* () {
  35. const permissions = yield* loadHomePermissions("C:\\Users\\test")
  36. expect(
  37. PermissionV2.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
  38. ).toBe("allow")
  39. expect(PermissionV2.evaluate("external_directory", "C:\\Users\\test\\cache\\files\\*", permissions).effect).toBe(
  40. "deny",
  41. )
  42. }),
  43. )
  44. it.effect("applies all global permissions before agent-specific permissions", () =>
  45. Effect.gen(function* () {
  46. const agents = yield* AgentV2.Service
  47. const build = AgentV2.ID.make("build")
  48. yield* agents.transform((editor) =>
  49. editor.update(build, (agent) => {
  50. agent.mode = "primary"
  51. agent.permissions.push({ action: "bash", resource: "*", effect: "allow" })
  52. }),
  53. )
  54. const config = Config.Service.of({
  55. entries: () =>
  56. Effect.succeed([
  57. new Config.Document({
  58. type: "document",
  59. info: decode({
  60. permissions: [{ action: "bash", resource: "*", effect: "ask" }],
  61. agents: {
  62. build: {
  63. permissions: [{ action: "bash", resource: "git *", effect: "allow" }],
  64. },
  65. reviewer: {
  66. model: "openrouter/openai/gpt-5",
  67. description: "Review changes",
  68. mode: "subagent",
  69. permissions: [
  70. { action: "edit", resource: "*", effect: "deny" },
  71. { action: "read", resource: "*", effect: "deny" },
  72. ],
  73. },
  74. removed: { description: "Removed later" },
  75. },
  76. }),
  77. }),
  78. new Config.Document({
  79. type: "document",
  80. info: decode({
  81. permissions: [{ action: "read", resource: "*", effect: "allow" }],
  82. agents: {
  83. reviewer: { variant: "high", hidden: true },
  84. removed: { disabled: true },
  85. late: {
  86. permissions: [{ action: "edit", resource: "*", effect: "allow" }],
  87. },
  88. },
  89. }),
  90. }),
  91. ]),
  92. })
  93. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  94. Effect.provideService(Config.Service, config),
  95. )
  96. const buildAgent = yield* agents.get(build)
  97. if (!buildAgent) throw new Error("expected configured build agent")
  98. expect(buildAgent.permissions).toEqual([
  99. { action: "bash", resource: "*", effect: "allow" },
  100. { action: "bash", resource: "*", effect: "ask" },
  101. { action: "read", resource: "*", effect: "allow" },
  102. { action: "bash", resource: "git *", effect: "allow" },
  103. ])
  104. expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
  105. expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
  106. const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
  107. if (!reviewer) throw new Error("expected configured reviewer agent")
  108. expect(reviewer).toMatchObject({
  109. description: "Review changes",
  110. mode: "subagent",
  111. hidden: true,
  112. model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
  113. })
  114. expect(reviewer.permissions).toEqual([
  115. { action: "bash", resource: "*", effect: "ask" },
  116. { action: "read", resource: "*", effect: "allow" },
  117. { action: "edit", resource: "*", effect: "deny" },
  118. { action: "read", resource: "*", effect: "deny" },
  119. ])
  120. expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
  121. expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
  122. { action: "bash", resource: "*", effect: "ask" },
  123. { action: "read", resource: "*", effect: "allow" },
  124. { action: "edit", resource: "*", effect: "allow" },
  125. ])
  126. expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
  127. }),
  128. )
  129. it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
  130. Effect.gen(function* () {
  131. const agents = yield* AgentV2.Service
  132. const config = Config.Service.of({
  133. entries: () =>
  134. Effect.succeed([
  135. new Config.Document({
  136. type: "document",
  137. info: decode({
  138. agents: {
  139. reviewer: {
  140. model: "anthropic/claude-sonnet",
  141. system: "Review carefully.",
  142. description: "Reviews changes",
  143. mode: "subagent",
  144. hidden: true,
  145. color: "warning",
  146. steps: 12,
  147. request: {
  148. headers: { first: "one", shared: "first" },
  149. body: { enabled: true, profile: "review", effort: "medium" },
  150. },
  151. },
  152. },
  153. }),
  154. }),
  155. new Config.Document({
  156. type: "document",
  157. info: decode({
  158. agents: {
  159. reviewer: {
  160. request: {
  161. headers: { shared: "last", second: "two" },
  162. body: { retries: 2, effort: "high" },
  163. },
  164. },
  165. },
  166. }),
  167. }),
  168. ]),
  169. })
  170. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  171. Effect.provideService(Config.Service, config),
  172. )
  173. const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
  174. if (!reviewer) throw new Error("expected configured reviewer agent")
  175. expect(reviewer).toMatchObject({
  176. system: "Review carefully.",
  177. description: "Reviews changes",
  178. mode: "subagent",
  179. hidden: true,
  180. color: "warning",
  181. steps: 12,
  182. model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined },
  183. })
  184. expect(reviewer.request).toEqual({
  185. headers: { first: "one", shared: "last", second: "two" },
  186. body: { enabled: true, profile: "review", retries: 2, effort: "high" },
  187. })
  188. }),
  189. )
  190. it.effect("removes a built-in agent disabled by configuration", () =>
  191. Effect.gen(function* () {
  192. const agents = yield* AgentV2.Service
  193. const build = AgentV2.ID.make("build")
  194. yield* agents.transform((editor) => editor.update(build, () => {}))
  195. const config = Config.Service.of({
  196. entries: () =>
  197. Effect.succeed([
  198. new Config.Document({
  199. type: "document",
  200. info: decode({ agents: { build: { disabled: true } } }),
  201. }),
  202. ]),
  203. })
  204. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  205. Effect.provideService(Config.Service, config),
  206. )
  207. expect(yield* agents.get(build)).toBeUndefined()
  208. }),
  209. )
  210. it.live("loads legacy file-based agents from config directories", () =>
  211. Effect.acquireRelease(
  212. Effect.promise(() => tmpdir()),
  213. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  214. ).pipe(
  215. Effect.flatMap((tmp) =>
  216. Effect.gen(function* () {
  217. yield* Effect.promise(async () => {
  218. await fs.mkdir(path.join(tmp.path, "agents", "team"), { recursive: true })
  219. await fs.mkdir(path.join(tmp.path, "modes"), { recursive: true })
  220. await fs.writeFile(
  221. path.join(tmp.path, "agents", "reviewer.md"),
  222. `---
  223. model: openrouter/openai/gpt-5
  224. description: Markdown description
  225. temperature: 0.5
  226. tools:
  227. write: false
  228. ---
  229. Review carefully.`,
  230. )
  231. await fs.writeFile(path.join(tmp.path, "agents", "team", "helper.md"), "Help the team.")
  232. await fs.writeFile(
  233. path.join(tmp.path, "agents", "native.md"),
  234. `---
  235. request:
  236. headers:
  237. x-agent: native
  238. body:
  239. effort: high
  240. permissions:
  241. - action: edit
  242. resource: "*"
  243. effect: deny
  244. ---
  245. Use native v2 fields.`,
  246. )
  247. await fs.writeFile(path.join(tmp.path, "agents", "disabled.md"), "---\ndisabled: true\n---\nDisabled")
  248. await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
  249. })
  250. const agents = yield* AgentV2.Service
  251. const config = Config.Service.of({
  252. entries: () =>
  253. Effect.succeed([
  254. new Config.Document({
  255. type: "document",
  256. info: decode({ agents: { reviewer: { description: "JSON description" } } }),
  257. }),
  258. new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
  259. ]),
  260. })
  261. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  262. Effect.provideService(Config.Service, config),
  263. )
  264. expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({
  265. model: { providerID: "openrouter", id: "openai/gpt-5" },
  266. system: "Review carefully.",
  267. description: "Markdown description",
  268. request: { body: { temperature: 0.5 } },
  269. permissions: [{ action: "edit", resource: "*", effect: "deny" }],
  270. })
  271. expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
  272. expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
  273. system: "Use native v2 fields.",
  274. request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
  275. permissions: [{ action: "edit", resource: "*", effect: "deny" }],
  276. })
  277. expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
  278. expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
  279. }),
  280. ),
  281. ),
  282. )
  283. })
  284. function loadHomePermissions(home: string) {
  285. return Effect.gen(function* () {
  286. const agents = yield* AgentV2.Service
  287. const build = AgentV2.ID.make("build")
  288. yield* agents.transform((editor) => editor.update(build, () => {}))
  289. const config = Config.Service.of({
  290. entries: () =>
  291. Effect.succeed([
  292. new Config.Document({
  293. type: "document",
  294. info: decode(
  295. ConfigMigrateV1.migrate({
  296. permission: {
  297. external_directory: {
  298. "~/p/**": "allow",
  299. "/some/~/path": "deny",
  300. "$HOMELESS/**": "deny",
  301. },
  302. bash: {
  303. "$HOME/private/**": "deny",
  304. },
  305. },
  306. agent: {
  307. build: {
  308. permission: {
  309. external_directory: {
  310. "$HOME/cache/**": "deny",
  311. },
  312. },
  313. },
  314. },
  315. }),
  316. ),
  317. }),
  318. ]),
  319. })
  320. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  321. Effect.provideService(Config.Service, config),
  322. Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
  323. )
  324. const agent = yield* agents.get(build)
  325. if (!agent) throw new Error("expected configured build agent")
  326. return agent.permissions
  327. })
  328. }