session-runner-tool-registry.test.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. import { describe, expect } from "bun:test"
  2. import { Tool, ToolFailure } from "@opencode-ai/llm"
  3. import { PermissionV2 } from "@opencode-ai/core/permission"
  4. import { SessionV2 } from "@opencode-ai/core/session"
  5. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  6. import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
  7. import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
  8. import { Effect, Exit, Layer, Schema, Scope } from "effect"
  9. import { testEffect } from "./lib/effect"
  10. const assertions: PermissionV2.AssertInput[] = []
  11. let denyAction: string | undefined
  12. const permission = Layer.succeed(
  13. PermissionV2.Service,
  14. PermissionV2.Service.of({
  15. assert: (input) =>
  16. Effect.sync(() => assertions.push(input)).pipe(
  17. Effect.andThen(
  18. input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
  19. ),
  20. ),
  21. ask: () => Effect.die("unused"),
  22. reply: () => Effect.die("unused"),
  23. get: () => Effect.die("unused"),
  24. forSession: () => Effect.die("unused"),
  25. list: () => Effect.die("unused"),
  26. }),
  27. )
  28. const bounds: ToolOutputStore.BoundInput[] = []
  29. const outputStore = Layer.mock(ToolOutputStore.Service, {
  30. bound: (input) => Effect.sync(() => bounds.push(input)).pipe(Effect.as({ output: input.output, outputPaths: [] })),
  31. })
  32. const registry = ToolRegistry.layer.pipe(
  33. Layer.provide(permission),
  34. Layer.provide(ApplicationTools.layer),
  35. Layer.provide(outputStore),
  36. )
  37. const it = testEffect(Layer.mergeAll(permission, registry))
  38. const echo = Tool.make({
  39. description: "Echo text",
  40. parameters: Schema.Struct({ text: Schema.String }),
  41. success: Schema.Struct({ text: Schema.String }),
  42. execute: ({ text }) => Effect.succeed({ text }),
  43. })
  44. describe("ToolRegistry", () => {
  45. it.effect("rebuilds advertised definitions when a scoped transform closes", () =>
  46. Effect.gen(function* () {
  47. const registry = yield* ToolRegistry.Service
  48. const scope = yield* Scope.make()
  49. const transform = yield* registry.transform().pipe(Scope.provide(scope))
  50. yield* transform((editor) => editor.set("echo", { tool: echo, authorize: () => Effect.void }))
  51. expect(yield* registry.definitions()).toMatchObject([{ name: "echo", description: "Echo text" }])
  52. yield* Scope.close(scope, Exit.void)
  53. expect(yield* registry.definitions()).toEqual([])
  54. }),
  55. )
  56. it.effect("returns an error result for an unknown tool", () =>
  57. Effect.gen(function* () {
  58. const registry = yield* ToolRegistry.Service
  59. expect(
  60. yield* registry.execute({
  61. sessionID: SessionV2.ID.make("ses_registry_test"),
  62. call: { type: "tool-call", id: "call-missing", name: "missing", input: {} },
  63. }),
  64. ).toEqual({ type: "error", value: "Unknown tool: missing" })
  65. }),
  66. )
  67. it.effect("does not execute a tool when authorization fails", () =>
  68. Effect.gen(function* () {
  69. const registry = yield* ToolRegistry.Service
  70. let executed = false
  71. const transform = yield* registry.transform()
  72. yield* transform((editor) =>
  73. editor.set("denied", {
  74. authorize: () => Effect.fail(new ToolFailure({ message: "Denied" })),
  75. tool: Tool.make({
  76. description: "Denied tool",
  77. parameters: Schema.Struct({}),
  78. success: Schema.Struct({ ok: Schema.Boolean }),
  79. execute: () =>
  80. Effect.sync(() => {
  81. executed = true
  82. return { ok: true }
  83. }),
  84. }),
  85. }),
  86. )
  87. expect(
  88. yield* registry.execute({
  89. sessionID: SessionV2.ID.make("ses_registry_test"),
  90. call: { type: "tool-call", id: "call-denied", name: "denied", input: {} },
  91. }),
  92. ).toEqual({ type: "error", value: "Denied" })
  93. expect(executed).toBe(false)
  94. }),
  95. )
  96. it.effect("binds invocation identity while preserving leaf-owned permission inputs", () =>
  97. Effect.gen(function* () {
  98. assertions.length = 0
  99. denyAction = undefined
  100. const registry = yield* ToolRegistry.Service
  101. const transform = yield* registry.transform()
  102. const sessionID = SessionV2.ID.make("ses_registry_context")
  103. yield* transform((editor) =>
  104. editor.set("context", {
  105. tool: Tool.make({
  106. description: "Context tool",
  107. parameters: Schema.Struct({}),
  108. success: Schema.Struct({ ok: Schema.Boolean }),
  109. }),
  110. execute: ({ assertPermission, call, source }) =>
  111. assertPermission({
  112. action: "inspect",
  113. resources: [call.id],
  114. save: ["*"],
  115. metadata: { tool: call.name },
  116. }).pipe(
  117. Effect.as({ ok: source === undefined }),
  118. Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" }))),
  119. ),
  120. }),
  121. )
  122. expect(
  123. yield* registry.execute({
  124. sessionID,
  125. call: { type: "tool-call", id: "call-context", name: "context", input: {} },
  126. }),
  127. ).toEqual({ type: "json", value: { ok: true } })
  128. expect(assertions).toEqual([
  129. {
  130. sessionID,
  131. action: "inspect",
  132. resources: ["call-context"],
  133. save: ["*"],
  134. metadata: { tool: "context" },
  135. },
  136. ])
  137. expect(assertions[0]).not.toHaveProperty("source")
  138. }),
  139. )
  140. it.effect("keeps ordered multi-assert policy flow in the leaf and stops on denial", () =>
  141. Effect.gen(function* () {
  142. assertions.length = 0
  143. denyAction = "execute"
  144. let executed = false
  145. const registry = yield* ToolRegistry.Service
  146. const transform = yield* registry.transform()
  147. yield* transform((editor) =>
  148. editor.set("ordered", {
  149. tool: Tool.make({
  150. description: "Ordered policy tool",
  151. parameters: Schema.Struct({}),
  152. success: Schema.Struct({ ok: Schema.Boolean }),
  153. }),
  154. execute: ({ assertPermission }) =>
  155. Effect.gen(function* () {
  156. yield* assertPermission({ action: "external_directory", resources: ["/outside/*"] })
  157. yield* assertPermission({ action: "execute", resources: ["pwd"] })
  158. executed = true
  159. return { ok: true }
  160. }).pipe(Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" })))),
  161. }),
  162. )
  163. expect(
  164. yield* registry.execute({
  165. sessionID: SessionV2.ID.make("ses_registry_context"),
  166. call: { type: "tool-call", id: "call-ordered", name: "ordered", input: {} },
  167. }),
  168. ).toEqual({ type: "error", value: "Denied" })
  169. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "execute"])
  170. expect(executed).toBe(false)
  171. denyAction = undefined
  172. }),
  173. )
  174. it.effect("settles encoded structured output with canonical projected content", () =>
  175. Effect.gen(function* () {
  176. bounds.length = 0
  177. const registry = yield* ToolRegistry.Service
  178. const transform = yield* registry.transform()
  179. yield* transform((editor) =>
  180. editor.set("projected", {
  181. tool: Tool.make({
  182. description: "Projected tool",
  183. parameters: Schema.Struct({ prefix: Schema.String }),
  184. success: Schema.Struct({ count: Schema.NumberFromString }),
  185. execute: () => Effect.succeed({ count: 2 }),
  186. toModelOutput: ({ callID, parameters, output }) => [
  187. { type: "text", text: `${callID}:${parameters.prefix}:${output.count}` },
  188. ],
  189. }),
  190. }),
  191. )
  192. expect(
  193. yield* registry.settle({
  194. sessionID: SessionV2.ID.make("ses_registry_test"),
  195. call: { type: "tool-call", id: "call-projected", name: "projected", input: { prefix: "count" } },
  196. }),
  197. ).toMatchObject({
  198. result: { type: "text", value: "call-projected:count:2" },
  199. output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
  200. })
  201. expect(bounds).toEqual([
  202. {
  203. sessionID: SessionV2.ID.make("ses_registry_test"),
  204. toolCallID: "call-projected",
  205. output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
  206. },
  207. ])
  208. }),
  209. )
  210. })