agent.test.ts 26 KB

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