agent.test.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654
  1. import { describe, expect, test } from "bun:test"
  2. import fs from "fs/promises"
  3. import path from "path"
  4. import { Effect, Fiber, Schema, Stream } from "effect"
  5. import { Agent } from "@opencode-ai/core/agent"
  6. import { Bus } from "@opencode-ai/core/bus"
  7. import { Config } from "@opencode-ai/core/config"
  8. import { Directory, Document, Info } from "@opencode-ai/schema/config"
  9. import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
  10. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  11. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  12. import { FSUtil } from "@opencode-ai/util/fs-util"
  13. import { Global } from "@opencode-ai/util/global"
  14. import { Permission } from "@opencode-ai/core/permission"
  15. import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
  16. import { AbsolutePath } from "@opencode-ai/core/schema"
  17. import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
  18. import { advance, drain } from "../lib/clock"
  19. import { tmpdir } from "../fixture/tmpdir"
  20. import { testEffect } from "../lib/effect"
  21. import { agentHost, host } from "../plugin/host"
  22. const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node])))
  23. const decode = Schema.decodeUnknownSync(Info)
  24. const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [
  25. ...Agent.Info.default(Agent.ID.make("test")).permissions,
  26. { action: "external_directory", resource: path.join(global.data, "shell", "*", "*"), effect: "allow" },
  27. { action: "external_directory", resource: path.join(global.data, "tool-output", "*"), effect: "allow" },
  28. { action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
  29. { action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
  30. ]
  31. test("rejects named agent color tokens", () => {
  32. expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow()
  33. })
  34. describe("ConfigAgentPlugin.Plugin", () => {
  35. it.effect("matches POSIX paths against home-relative permissions", () =>
  36. Effect.gen(function* () {
  37. const permissions = yield* loadHomePermissions("/home/test")
  38. expect(Permission.evaluate("external_directory", "/home/test/p/opencode/src/*", permissions).effect).toBe("allow")
  39. expect(Permission.evaluate("external_directory", "/home/test/cache/files/*", permissions).effect).toBe("deny")
  40. expect(Permission.evaluate("external_directory", "/some/~/path", permissions).effect).toBe("deny")
  41. expect(Permission.evaluate("external_directory", "$HOMELESS/private/*", permissions).effect).toBe("deny")
  42. expect(permissions).toContainEqual({ action: "shell", resource: "$HOME/private/**", effect: "deny" })
  43. expect(permissions).not.toContainEqual({ action: "shell", resource: "/home/test/private/**", effect: "deny" })
  44. expect(Permission.evaluate("shell", "$HOME/private/key", permissions).effect).toBe("deny")
  45. }),
  46. )
  47. it.effect("matches Windows paths against home-relative permissions", () =>
  48. Effect.gen(function* () {
  49. const permissions = yield* loadHomePermissions("C:\\Users\\test")
  50. expect(
  51. Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
  52. ).toBe("allow")
  53. expect(Permission.evaluate("external_directory", "C:\\Users\\test\\cache\\files\\*", permissions).effect).toBe(
  54. "deny",
  55. )
  56. }),
  57. )
  58. it.effect("applies remote permission defaults before explicit global and build rules", () =>
  59. Effect.gen(function* () {
  60. const agents = yield* Agent.Service
  61. const global = yield* Global.Service
  62. yield* AgentPlugin.Plugin.effect(host({ agent: agentHost(agents) }))
  63. const entries = [
  64. new Document({
  65. type: "document",
  66. info: decode(
  67. ConfigMigrateV1.migrate({
  68. permission: {
  69. bash: "ask",
  70. edit: "ask",
  71. webfetch: "ask",
  72. read: {
  73. "*": "allow",
  74. "*.env": "deny",
  75. "*.env.*": "deny",
  76. "*.env.example": "allow",
  77. "*.dev.vars": "deny",
  78. "~/.local/share/opencode/mcp-auth.json": "deny",
  79. "$HOME/.local/share/opencode/mcp-auth.json": "deny",
  80. },
  81. external_directory: {
  82. "*": "ask",
  83. "~/.local/share/opencode/*": "deny",
  84. },
  85. },
  86. }),
  87. ),
  88. }),
  89. new Document({
  90. type: "document",
  91. info: decode({
  92. permissions: [{ action: "*", resource: "*", effect: "allow" }],
  93. agents: {
  94. build: {
  95. permissions: [
  96. { action: "external_directory", resource: "*", effect: "allow" },
  97. {
  98. action: "external_directory",
  99. resource: "~/.local/share/opencode/*",
  100. effect: "deny",
  101. },
  102. { action: "read", resource: "*.env", effect: "deny" },
  103. ],
  104. },
  105. },
  106. }),
  107. }),
  108. ]
  109. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  110. Effect.provide(Config.testLayer(entries)),
  111. )
  112. const build = yield* agents.get(Agent.defaultID)
  113. if (!build) throw new Error("expected configured build agent")
  114. const opencodeData = path.join(global.home, ".local", "share", "opencode", "*")
  115. const mcpAuth = path.join(global.home, ".local", "share", "opencode", "mcp-auth.json")
  116. expect(build.permissions).toEqual([
  117. ...defaultPermissions(global),
  118. { action: "question", resource: "*", effect: "allow" },
  119. { action: "shell", resource: "*", effect: "ask" },
  120. { action: "edit", resource: "*", effect: "ask" },
  121. { action: "webfetch", resource: "*", effect: "ask" },
  122. { action: "read", resource: "*", effect: "allow" },
  123. { action: "read", resource: "*.env", effect: "deny" },
  124. { action: "read", resource: "*.env.*", effect: "deny" },
  125. { action: "read", resource: "*.env.example", effect: "allow" },
  126. { action: "read", resource: "*.dev.vars", effect: "deny" },
  127. { action: "read", resource: mcpAuth, effect: "deny" },
  128. { action: "read", resource: mcpAuth, effect: "deny" },
  129. { action: "external_directory", resource: "*", effect: "ask" },
  130. { action: "external_directory", resource: opencodeData, effect: "deny" },
  131. { action: "*", resource: "*", effect: "allow" },
  132. { action: "external_directory", resource: "*", effect: "allow" },
  133. { action: "external_directory", resource: opencodeData, effect: "deny" },
  134. { action: "read", resource: "*.env", effect: "deny" },
  135. ])
  136. expect(Permission.evaluate("shell", "bun test", build.permissions).effect).toBe("allow")
  137. expect(Permission.evaluate("edit", "src/index.ts", build.permissions).effect).toBe("allow")
  138. expect(Permission.evaluate("webfetch", "https://example.com", build.permissions).effect).toBe("allow")
  139. expect(Permission.evaluate("read", ".env", build.permissions).effect).toBe("deny")
  140. expect(Permission.evaluate("external_directory", opencodeData, build.permissions).effect).toBe("deny")
  141. expect(Permission.evaluate("external_directory", "/outside/*", build.permissions).effect).toBe("allow")
  142. }),
  143. )
  144. it.effect("applies all global permissions before agent-specific permissions", () =>
  145. Effect.gen(function* () {
  146. const agents = yield* Agent.Service
  147. const global = yield* Global.Service
  148. const build = Agent.ID.make("build")
  149. yield* agents.transform((editor) =>
  150. editor.update(build, (agent) => {
  151. agent.mode = "primary"
  152. agent.permissions.push({ action: "bash", resource: "*", effect: "allow" })
  153. }),
  154. )
  155. const entries = [
  156. new Document({
  157. type: "document",
  158. info: decode({
  159. permissions: [{ action: "bash", resource: "*", effect: "ask" }],
  160. agents: {
  161. build: {
  162. permissions: [{ action: "bash", resource: "git *", effect: "allow" }],
  163. },
  164. reviewer: {
  165. model: "openrouter/openai/gpt-5",
  166. description: "Review changes",
  167. mode: "subagent",
  168. permissions: [
  169. { action: "edit", resource: "*", effect: "deny" },
  170. { action: "read", resource: "*", effect: "deny" },
  171. ],
  172. },
  173. removed: { description: "Removed later" },
  174. },
  175. }),
  176. }),
  177. new Document({
  178. type: "document",
  179. info: decode({
  180. permissions: [{ action: "read", resource: "*", effect: "allow" }],
  181. agents: {
  182. reviewer: { model: "openrouter/openai/gpt-5#high", hidden: true },
  183. removed: { disabled: true },
  184. late: {
  185. permissions: [{ action: "edit", resource: "*", effect: "allow" }],
  186. },
  187. },
  188. }),
  189. }),
  190. ]
  191. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  192. Effect.provide(Config.testLayer(entries)),
  193. )
  194. const buildAgent = yield* agents.get(build)
  195. if (!buildAgent) throw new Error("expected configured build agent")
  196. expect(buildAgent.permissions).toEqual([
  197. ...defaultPermissions(global),
  198. { action: "bash", resource: "*", effect: "allow" },
  199. { action: "bash", resource: "*", effect: "ask" },
  200. { action: "read", resource: "*", effect: "allow" },
  201. { action: "bash", resource: "git *", effect: "allow" },
  202. ])
  203. expect(Permission.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
  204. expect(Permission.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
  205. const reviewer = yield* agents.get(Agent.ID.make("reviewer"))
  206. if (!reviewer) throw new Error("expected configured reviewer agent")
  207. expect(reviewer).toMatchObject({
  208. description: "Review changes",
  209. mode: "subagent",
  210. hidden: true,
  211. model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
  212. })
  213. expect(reviewer.permissions).toEqual([
  214. ...defaultPermissions(global),
  215. { action: "bash", resource: "*", effect: "ask" },
  216. { action: "read", resource: "*", effect: "allow" },
  217. { action: "edit", resource: "*", effect: "deny" },
  218. { action: "read", resource: "*", effect: "deny" },
  219. ])
  220. expect(Permission.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
  221. expect((yield* agents.get(Agent.ID.make("late")))?.permissions).toEqual([
  222. ...defaultPermissions(global),
  223. { action: "bash", resource: "*", effect: "ask" },
  224. { action: "read", resource: "*", effect: "allow" },
  225. { action: "edit", resource: "*", effect: "allow" },
  226. ])
  227. expect(yield* agents.get(Agent.ID.make("removed"))).toBeUndefined()
  228. }),
  229. )
  230. it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
  231. Effect.gen(function* () {
  232. const agents = yield* Agent.Service
  233. const entries = [
  234. new Document({
  235. type: "document",
  236. info: decode({
  237. agents: {
  238. reviewer: {
  239. model: "anthropic/claude-sonnet",
  240. system: "Review carefully.",
  241. description: "Reviews changes",
  242. mode: "subagent",
  243. hidden: true,
  244. color: "#ff6b6b",
  245. steps: 12,
  246. request: {
  247. headers: { first: "one", shared: "first" },
  248. body: { enabled: true, profile: "review", effort: "medium" },
  249. },
  250. },
  251. },
  252. }),
  253. }),
  254. new Document({
  255. type: "document",
  256. info: decode({
  257. agents: {
  258. reviewer: {
  259. request: {
  260. headers: { shared: "last", second: "two" },
  261. body: { retries: 2, effort: "high" },
  262. },
  263. },
  264. },
  265. }),
  266. }),
  267. ]
  268. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  269. Effect.provide(Config.testLayer(entries)),
  270. )
  271. const reviewer = yield* agents.get(Agent.ID.make("reviewer"))
  272. if (!reviewer) throw new Error("expected configured reviewer agent")
  273. expect(reviewer).toMatchObject({
  274. system: "Review carefully.",
  275. description: "Reviews changes",
  276. mode: "subagent",
  277. hidden: true,
  278. color: "#ff6b6b",
  279. steps: 12,
  280. model: { providerID: "anthropic", id: "claude-sonnet" },
  281. })
  282. expect(reviewer.request).toEqual({
  283. settings: {},
  284. headers: { first: "one", shared: "last", second: "two" },
  285. body: { enabled: true, profile: "review", retries: 2, effort: "high" },
  286. })
  287. }),
  288. )
  289. it.effect("removes a built-in agent disabled by configuration", () =>
  290. Effect.gen(function* () {
  291. const agents = yield* Agent.Service
  292. const build = Agent.ID.make("build")
  293. yield* agents.transform((editor) => editor.update(build, () => {}))
  294. const entries = [
  295. new Document({
  296. type: "document",
  297. info: decode({ agents: { build: { disabled: true } } }),
  298. }),
  299. ]
  300. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  301. Effect.provide(Config.testLayer(entries)),
  302. )
  303. expect(yield* agents.get(build)).toBeUndefined()
  304. }),
  305. )
  306. it.live("loads legacy file-based agents from config directories", () =>
  307. Effect.acquireRelease(
  308. Effect.promise(() => tmpdir()),
  309. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  310. ).pipe(
  311. Effect.flatMap((tmp) =>
  312. Effect.gen(function* () {
  313. yield* Effect.promise(async () => {
  314. await fs.mkdir(path.join(tmp.path, "agents", "team"), { recursive: true })
  315. await fs.mkdir(path.join(tmp.path, "modes"), { recursive: true })
  316. await fs.writeFile(
  317. path.join(tmp.path, "agents", "reviewer.md"),
  318. `---
  319. model: openrouter/openai/gpt-5
  320. description: Markdown description
  321. temperature: 0.5
  322. tools:
  323. write: false
  324. ---
  325. Review carefully.`,
  326. )
  327. await fs.writeFile(path.join(tmp.path, "agents", "team", "helper.md"), "Help the team.")
  328. await fs.writeFile(
  329. path.join(tmp.path, "agents", "native.md"),
  330. `---
  331. request:
  332. headers:
  333. x-agent: native
  334. body:
  335. effort: high
  336. permissions:
  337. - action: edit
  338. resource: "*"
  339. effect: deny
  340. ---
  341. Use native v2 fields.`,
  342. )
  343. await fs.writeFile(path.join(tmp.path, "agents", "disabled.md"), "---\ndisabled: true\n---\nDisabled")
  344. await fs.writeFile(path.join(tmp.path, "agents", "empty.md"), "")
  345. await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
  346. })
  347. const agents = yield* Agent.Service
  348. const global = yield* Global.Service
  349. const entries = [
  350. new Document({
  351. type: "document",
  352. info: decode({ agents: { reviewer: { description: "JSON description" } } }),
  353. }),
  354. directoryEntry(tmp.path),
  355. ]
  356. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  357. Effect.provide(Config.testLayer(entries)),
  358. )
  359. expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
  360. model: { providerID: "openrouter", id: "openai/gpt-5" },
  361. system: "Review carefully.",
  362. description: "Markdown description",
  363. request: { body: { temperature: 0.5 } },
  364. permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }],
  365. })
  366. expect(yield* agents.get(Agent.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
  367. expect(yield* agents.get(Agent.ID.make("native"))).toMatchObject({
  368. system: "Use native v2 fields.",
  369. request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
  370. permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }],
  371. })
  372. expect(yield* agents.get(Agent.ID.make("disabled"))).toBeUndefined()
  373. expect(yield* agents.get(Agent.ID.make("empty"))).toBeUndefined()
  374. expect(yield* agents.get(Agent.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
  375. }),
  376. ),
  377. ),
  378. )
  379. for (const testCase of sourceCases()) {
  380. it.effect(`rebuilds agents when a source file is ${testCase.name}`, () =>
  381. Effect.acquireRelease(
  382. Effect.promise(() => tmpdir()),
  383. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  384. ).pipe(
  385. Effect.flatMap((tmp) =>
  386. Effect.gen(function* () {
  387. const directory = path.join(tmp.path, testCase.source)
  388. yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
  389. yield* testCase.prepare(directory)
  390. const agents = yield* Agent.Service
  391. const bus = yield* Bus.Service
  392. const configTest = yield* Config.Test
  393. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) }))
  394. // Verify inside the subscription so the update event is a read barrier:
  395. // committed state must be visible at event delivery time.
  396. let received = 0
  397. const changed = yield* bus.subscribe(Agent.Event.Updated).pipe(
  398. Stream.take(1),
  399. Stream.tap(() => Effect.sync(() => received++)),
  400. Stream.mapEffect(() => testCase.verify(agents)),
  401. Stream.runDrain,
  402. Effect.forkScoped({ startImmediately: true }),
  403. )
  404. yield* Effect.yieldNow
  405. const updates = yield* testCase.mutate(directory)
  406. yield* Effect.forEach(updates, (update) => configTest.emitChange(update), { discard: true })
  407. yield* advance(() => received === 1)
  408. yield* Fiber.join(changed)
  409. }).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
  410. ),
  411. ),
  412. )
  413. }
  414. it.effect("coalesces updates inside the debounce window into one rebuild", () =>
  415. Effect.acquireRelease(
  416. Effect.promise(() => tmpdir()),
  417. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  418. ).pipe(
  419. Effect.flatMap((tmp) =>
  420. Effect.gen(function* () {
  421. const directory = path.join(tmp.path, "agents")
  422. yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
  423. const agents = yield* Agent.Service
  424. const configTest = yield* Config.Test
  425. let reloads = 0
  426. yield* ConfigAgentPlugin.Plugin.effect(
  427. host({
  428. agent: {
  429. ...agentHost(agents),
  430. reload: () => agents.reload().pipe(Effect.tap(() => Effect.sync(() => reloads++))),
  431. },
  432. }),
  433. )
  434. yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review once"))
  435. yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
  436. yield* configTest.emitChange({ type: "update", path: path.join(directory, "reviewer.md") })
  437. yield* configTest.emitChange({ type: "update", path: path.join(directory, "reviewer.md") })
  438. yield* advance(() => reloads >= 1)
  439. expect(reloads).toBe(1)
  440. yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review twice"))
  441. yield* configTest.emitChange({ type: "update", path: path.join(directory, "reviewer.md") })
  442. yield* advance(() => reloads >= 2)
  443. expect(reloads).toBe(2)
  444. expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({ system: "Review twice" })
  445. }).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
  446. ),
  447. ),
  448. )
  449. it.effect("ignores updates outside agent source directories", () =>
  450. Effect.acquireRelease(
  451. Effect.promise(() => tmpdir()),
  452. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  453. ).pipe(
  454. Effect.flatMap((tmp) =>
  455. Effect.gen(function* () {
  456. const directory = path.join(tmp.path, "agents")
  457. yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
  458. const agents = yield* Agent.Service
  459. const configTest = yield* Config.Test
  460. let reloads = 0
  461. yield* ConfigAgentPlugin.Plugin.effect(
  462. host({
  463. agent: {
  464. ...agentHost(agents),
  465. reload: () => agents.reload().pipe(Effect.tap(() => Effect.sync(() => reloads++))),
  466. },
  467. }),
  468. )
  469. yield* configTest.emitChange({ type: "create", path: path.join(tmp.path, "commands", "review.md") })
  470. yield* configTest.emitChange({ type: "update", path: path.join(tmp.path, "opencode.json") })
  471. yield* drain
  472. expect(reloads).toBe(0)
  473. // The feed stays live after unrelated updates.
  474. yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review related"))
  475. yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
  476. yield* advance(() => reloads >= 1)
  477. expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({ system: "Review related" })
  478. }).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
  479. ),
  480. ),
  481. )
  482. })
  483. function directoryEntry(directory: string) {
  484. return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
  485. }
  486. function sourceCases() {
  487. return [
  488. {
  489. name: "created",
  490. source: "agents",
  491. prepare: () => Effect.void,
  492. mutate: (directory: string) =>
  493. Effect.promise(async () => {
  494. const file = path.join(directory, "reviewer.md")
  495. await fs.writeFile(file, "Review changes")
  496. return [{ type: "create" as const, path: file }]
  497. }),
  498. verify: (agents: Agent.Interface) =>
  499. Effect.gen(function* () {
  500. expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({ system: "Review changes" })
  501. }),
  502. },
  503. {
  504. name: "created in a legacy modes directory",
  505. source: "modes",
  506. prepare: () => Effect.void,
  507. mutate: (directory: string) =>
  508. Effect.promise(async () => {
  509. const file = path.join(directory, "plan.md")
  510. await fs.writeFile(file, "Make a plan")
  511. return [{ type: "create" as const, path: file }]
  512. }),
  513. verify: (agents: Agent.Interface) =>
  514. Effect.gen(function* () {
  515. expect(yield* agents.get(Agent.ID.make("plan"))).toMatchObject({ system: "Make a plan", mode: "primary" })
  516. }),
  517. },
  518. {
  519. name: "updated",
  520. source: "agents",
  521. prepare: (directory: string) =>
  522. Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review first")),
  523. mutate: (directory: string) =>
  524. Effect.promise(async () => {
  525. const file = path.join(directory, "reviewer.md")
  526. await fs.writeFile(file, "Review updated")
  527. return [{ type: "update" as const, path: file }]
  528. }),
  529. verify: (agents: Agent.Interface) =>
  530. Effect.gen(function* () {
  531. expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({ system: "Review updated" })
  532. }),
  533. },
  534. {
  535. name: "renamed",
  536. source: "agents",
  537. prepare: (directory: string) =>
  538. Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review renamed")),
  539. mutate: (directory: string) =>
  540. Effect.promise(async () => {
  541. const previous = path.join(directory, "reviewer.md")
  542. const next = path.join(directory, "release.md")
  543. await fs.rename(previous, next)
  544. return [
  545. { type: "delete" as const, path: previous },
  546. { type: "create" as const, path: next },
  547. ]
  548. }),
  549. verify: (agents: Agent.Interface) =>
  550. Effect.gen(function* () {
  551. expect(yield* agents.get(Agent.ID.make("reviewer"))).toBeUndefined()
  552. expect(yield* agents.get(Agent.ID.make("release"))).toMatchObject({ system: "Review renamed" })
  553. }),
  554. },
  555. {
  556. name: "deleted",
  557. source: "agents",
  558. prepare: (directory: string) =>
  559. Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review deleted")),
  560. mutate: (directory: string) =>
  561. Effect.promise(async () => {
  562. const file = path.join(directory, "reviewer.md")
  563. await fs.unlink(file)
  564. return [{ type: "delete" as const, path: file }]
  565. }),
  566. verify: (agents: Agent.Interface) =>
  567. Effect.gen(function* () {
  568. expect(yield* agents.get(Agent.ID.make("reviewer"))).toBeUndefined()
  569. }),
  570. },
  571. ] as const
  572. }
  573. function loadHomePermissions(home: string) {
  574. return Effect.gen(function* () {
  575. const agents = yield* Agent.Service
  576. const build = Agent.ID.make("build")
  577. yield* agents.transform((editor) => editor.update(build, () => {}))
  578. const entries = [
  579. new Document({
  580. type: "document",
  581. info: decode(
  582. ConfigMigrateV1.migrate({
  583. permission: {
  584. external_directory: {
  585. "~/p/**": "allow",
  586. "/some/~/path": "deny",
  587. "$HOMELESS/**": "deny",
  588. },
  589. bash: {
  590. "$HOME/private/**": "deny",
  591. },
  592. },
  593. agent: {
  594. build: {
  595. permission: {
  596. external_directory: {
  597. "$HOME/cache/**": "deny",
  598. },
  599. },
  600. },
  601. },
  602. }),
  603. ),
  604. }),
  605. ]
  606. yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
  607. Effect.provide(Config.testLayer(entries)),
  608. Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
  609. )
  610. const agent = yield* agents.get(build)
  611. if (!agent) throw new Error("expected configured build agent")
  612. return agent.permissions
  613. })
  614. }