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

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