agent.test.ts 14 KB

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