agent.test.ts 14 KB

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