1
0

tools.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. /** Agent controls use the same manager methods as the Web and bound inventory reads. */
  2. import { Context } from '@deepseek-ai/cordis'
  3. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  4. import ToolRuntime from '@deepseek-ai/dsh-tools'
  5. import { ToolCallId } from '@deepseek-ai/dsh-llm'
  6. import type { Agent } from '@deepseek-ai/dsh-agent'
  7. import SandboxPolicy, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
  8. import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
  9. import SessionProjections from '@deepseek-ai/dsh-session-projection'
  10. import ApprovalService, { type ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
  11. import { expect, it, onTestFinished, vi } from 'vitest'
  12. import type PluginManager from '../src/index.ts'
  13. import * as tool from '../src/tools.ts'
  14. function resultText(result: Awaited<ReturnType<ToolRuntime['execute']>>): string {
  15. if (typeof result.value !== 'string') throw new Error('Expected a serialized manager result')
  16. return result.value
  17. }
  18. async function fixture(mode: 'read-only' | 'workspace-write' | 'danger-full-access' = 'danger-full-access', approval?: 'ask' | 'never') {
  19. const ctx = new Context()
  20. onTestFinished(() => ctx.fiber.dispose())
  21. const manager = {
  22. listPlugins: vi.fn(async () => Array.from({ length: 30 }, (_, i) => ({ entryId: `include:${i}`, enabled: true }))),
  23. listBundles: vi.fn(async () => [{ name: 'bundle', enabled: true }]),
  24. setPluginEnabled: vi.fn(async () => ({ changed: true, application: 'applied' })),
  25. setBundleEnabled: vi.fn(async () => ({ changed: true, application: 'applied' })),
  26. installBundle: vi.fn(async () => ({ changed: true, application: 'restart-required' })),
  27. removeBundle: vi.fn(async () => ({ changed: false, application: 'failed' })),
  28. }
  29. ctx.provide('pluginManager', manager as unknown as PluginManager)
  30. await ctx.plugin(SystemPrompt)
  31. await ctx.plugin(ToolRuntime)
  32. await ctx.plugin(SessionProjections)
  33. await ctx.plugin(SandboxPolicy, { mode })
  34. if (approval !== undefined) await ctx.plugin(ApprovalService, { policy: approval })
  35. const fiber = await ctx.plugin(tool)
  36. const call = (args: unknown, agent?: Agent, signal = new AbortController().signal) => ctx.tools.execute({ name: 'plugin_manager', arguments: args,
  37. ...agent === undefined ? {} : { agent },
  38. callId: ToolCallId('manager-call'), signal })
  39. return { ctx, manager, call, fiber }
  40. }
  41. it.each(['read-only', 'workspace-write'] as const)('denies every management action in %s before accessing the manager', async (mode) => {
  42. const { call, manager } = await fixture(mode)
  43. for (const action of ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle']) {
  44. const result = await call({ action, target: 'bundle', enabled: true })
  45. expect(result.isError).toBe(true)
  46. expect(JSON.stringify(result.content)).toContain('requires approval, but no approval service is composed')
  47. }
  48. for (const method of Object.values(manager)) expect(method).not.toHaveBeenCalled()
  49. })
  50. it('checks the calling session on each execution, including after permission is revoked', async () => {
  51. const { call, manager } = await fixture()
  52. const id = SessionId('manager-permissions')
  53. const session = Session.create(id, undefined, { version: SESSION_FORMAT_VERSION, id, createdAt: 0, isSeeded: false })
  54. const agent = { session } as unknown as Agent
  55. setSandboxMode(session, 'workspace-write')
  56. expect((await call({ action: 'list_plugins' }, agent)).isError).toBe(true)
  57. expect(manager.listPlugins).not.toHaveBeenCalled()
  58. setSandboxMode(session, 'danger-full-access')
  59. expect((await call({ action: 'list_plugins' }, agent)).isError).toBe(false)
  60. setSandboxMode(session, 'read-only')
  61. expect((await call({ action: 'list_plugins' }, agent)).isError).toBe(true)
  62. expect(manager.listPlugins).toHaveBeenCalledTimes(1)
  63. })
  64. function activeAgent(): Agent {
  65. const id = SessionId('manager-approval')
  66. const session = Session.create(id, undefined, { version: SESSION_FORMAT_VERSION, id, createdAt: 0, isSeeded: false })
  67. session.append('turn/start', { turn: 1 })
  68. return { session } as unknown as Agent
  69. }
  70. it.each(['read-only', 'workspace-write'] as const)('approves each action once in %s without changing session permissions', async (mode) => {
  71. const { ctx, call, manager } = await fixture(mode, 'ask')
  72. const agent = activeAgent()
  73. const prompted = vi.fn(async () => 'allowed-once' as const)
  74. const dispose = ctx.on('approval/request', prompted)
  75. for (const action of ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle']) {
  76. expect((await call({ action, target: 'bundle', enabled: true }, agent)).isError).toBe(false)
  77. }
  78. expect(prompted).toHaveBeenCalledTimes(6)
  79. for (const method of Object.values(manager)) expect(method).toHaveBeenCalledTimes(1)
  80. expect(ctx.sandboxPolicy.resolve({ session: agent.session }).mode).toBe(mode)
  81. const audit = agent.session.snapshotEvents().filter(event => event.type.startsWith('approval/'))
  82. expect(audit).toHaveLength(12)
  83. expect(audit[0]).toMatchObject({ type: 'approval/asked', data: {
  84. toolName: 'plugin_manager', callId: 'manager-call',
  85. } })
  86. const request = audit[0]
  87. if (request?.type !== 'approval/asked') throw new Error('Expected an approval request')
  88. expect(request.data.reason).toContain('"action":"list_plugins"')
  89. expect(audit[1]).toMatchObject({ type: 'approval/decided', data: { outcome: 'allowed-once' } })
  90. dispose()
  91. expect((await call({ action: 'list_plugins' }, agent)).isError).toBe(true)
  92. expect(manager.listPlugins).toHaveBeenCalledTimes(1)
  93. })
  94. it.each(['rejected', 'cancelled', 'unavailable'] as const)('does not mutate the profile when approval is %s', async (outcome) => {
  95. const { ctx, call, manager } = await fixture('workspace-write', 'ask')
  96. ctx.on('approval/request', async () => outcome)
  97. const agent = activeAgent()
  98. expect((await call({ action: 'install_bundle', target: 'bundle' }, agent)).isError).toBe(true)
  99. expect(manager.installBundle).not.toHaveBeenCalled()
  100. expect(agent.session.snapshotEvents().filter(event => event.type === 'approval/decided')
  101. .map(event => event.data.outcome)).toEqual([outcome])
  102. })
  103. it('rejects never policy without prompting and keeps full-access calls prompt-free', async () => {
  104. const { ctx, call, manager } = await fixture('workspace-write', 'never')
  105. const prompted = vi.fn(async () => 'allowed-once' as const)
  106. ctx.on('approval/request', prompted, { prepend: true })
  107. const agent = activeAgent()
  108. expect((await call({ action: 'set_plugin', target: 'include:demo', enabled: true }, agent)).isError).toBe(true)
  109. expect(manager.setPluginEnabled).not.toHaveBeenCalled()
  110. setSandboxMode(agent.session, 'danger-full-access')
  111. expect((await call({ action: 'set_plugin', target: 'include:demo', enabled: true }, agent)).isError).toBe(false)
  112. expect(manager.setPluginEnabled).toHaveBeenCalledTimes(1)
  113. expect(prompted).not.toHaveBeenCalled()
  114. })
  115. it('cancels an approval wait before any manager operation', async () => {
  116. const { ctx, call, manager } = await fixture('workspace-write', 'ask')
  117. const asked = Promise.withResolvers<undefined>()
  118. const answer = Promise.withResolvers<ApprovalOutcome>()
  119. ctx.on('approval/request', () => { asked.resolve(undefined); return answer.promise })
  120. const controller = new AbortController()
  121. const result = call({ action: 'install_bundle', target: 'bundle' }, activeAgent(), controller.signal)
  122. await asked.promise
  123. expect(manager.installBundle).not.toHaveBeenCalled()
  124. controller.abort()
  125. answer.resolve('allowed-once')
  126. expect((await result).isError).toBe(true)
  127. expect(manager.installBundle).not.toHaveBeenCalled()
  128. })
  129. it('does not apply a grant when the call was cancelled before dispatch', async () => {
  130. const { ctx, call, manager } = await fixture('workspace-write', 'ask')
  131. const controller = new AbortController()
  132. vi.spyOn(ctx.approval, 'request').mockImplementation(async () => {
  133. controller.abort()
  134. return 'allowed-once'
  135. })
  136. expect((await call({ action: 'install_bundle', target: 'bundle' }, activeAgent(), controller.signal)).isError).toBe(true)
  137. expect(manager.installBundle).not.toHaveBeenCalled()
  138. })
  139. it('paginates inventories with an explicit continuation and total', async () => {
  140. const { call } = await fixture()
  141. const first = await call({ action: 'list_plugins' })
  142. expect(first.isError).toBe(false)
  143. expect(JSON.stringify(first.content)).toContain('nextOffset')
  144. expect(JSON.parse(resultText(first))).toMatchObject({ nextOffset: 25, total: 30 })
  145. const last = await call({ action: 'list_plugins', offset: 25, limit: 10 })
  146. expect(JSON.parse(resultText(last))).toMatchObject({ nextOffset: null, total: 30 })
  147. expect(resultText(await call({ action: 'list_bundles' }))).toContain('"name":"bundle"')
  148. })
  149. it('forwards all mutation actions and renders the returned outcome', async () => {
  150. const { call, manager } = await fixture()
  151. await call({ action: 'set_plugin', target: 'include:1', enabled: false })
  152. expect(manager.setPluginEnabled).toHaveBeenCalledWith('include:1', false)
  153. await call({ action: 'set_bundle', target: 'bundle', enabled: true })
  154. expect(manager.setBundleEnabled).toHaveBeenCalledWith('bundle', true)
  155. await call({ action: 'install_bundle', target: 'bundle' })
  156. expect(manager.installBundle).toHaveBeenLastCalledWith('bundle', {})
  157. await call({ action: 'install_bundle', target: 'bundle', enabled: false })
  158. expect(manager.installBundle).toHaveBeenLastCalledWith('bundle', { enabled: false })
  159. await call({ action: 'install_bundle', target: 'bundle', approvedBuilds: ['native'] })
  160. expect(manager.installBundle).toHaveBeenLastCalledWith('bundle', { approvedBuilds: ['native'] })
  161. expect(resultText(await call({ action: 'remove_bundle', target: 'bundle' }))).toContain('"application":"failed"')
  162. expect(manager.removeBundle).toHaveBeenCalledWith('bundle')
  163. })
  164. it.each([
  165. { action: 'unknown_action' },
  166. { action: 'list_plugins', offset: -1 },
  167. { action: 'list_plugins', offset: 0.5 },
  168. { action: 'list_bundles', limit: 101 },
  169. { action: 'list_bundles', limit: 0 },
  170. { action: 'list_bundles', limit: 1.5 },
  171. { action: 'set_plugin', enabled: true },
  172. { action: 'set_bundle', target: 'bundle' },
  173. { action: 'install_bundle' }, { action: 'remove_bundle' },
  174. ])('rejects incomplete or unbounded tool inputs: %j', async (args) => {
  175. const { call } = await fixture()
  176. expect((await call(args)).isError).toBe(true)
  177. })
  178. it('presents reads and changes distinctly and disposes its registration', async () => {
  179. const { ctx, fiber } = await fixture()
  180. const definition = ctx.tools.get('plugin_manager')!
  181. expect(definition.presentCall?.({ action: 'list_plugins' })).toMatchObject({ kind: 'read' })
  182. expect(definition.presentCall?.({ action: 'remove_bundle', target: 'bundle' })).toMatchObject({ kind: 'other' })
  183. await fiber.dispose()
  184. expect(ctx.tools.get('plugin_manager')).toBeUndefined()
  185. })