agent.test.ts 22 KB

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