workspacePlugins.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. // @vitest-environment node
  2. import { beforeEach, afterEach, describe, it, expect } from 'vitest'
  3. import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { randomUUID } from 'node:crypto'
  7. import request from 'supertest'
  8. import type Koa from 'koa'
  9. import { createApplication, type ApplicationRuntime } from '../../src/server/app'
  10. import { loadServerConfig } from '../../src/server/config'
  11. import { LocalAuthStore } from '../../src/server/localAuth'
  12. import { HttpMcp } from '../../src/server/botPlugins/httpMcp'
  13. import { createWorkspaceToolLease } from '../../src/server/workspaceToolLease'
  14. let home: string, runtime: ApplicationRuntime, cookie: string, csrf: string, failedInventory: boolean
  15. let requests: Array<{ url: string; body: any; method: string }>, accounts: any[], sessions: Map<string, string>
  16. const principal = (id: string) => ({ id, username: id, role: id === 'owner' ? 'admin' as const : 'user' as const, enabled: true, mustChangePassword: false, createdAt: 1, updatedAt: 1 })
  17. class TestAuth extends LocalAuthStore {
  18. override current(ctx: Koa.Context) { return principal(ctx.get('x-test-user') || 'owner') }
  19. override require(ctx: Koa.Context) { return this.current(ctx) }
  20. override requireAdmin(ctx: Koa.Context) { const user = this.current(ctx); if (user.role !== 'admin') ctx.throw(403); return user }
  21. override isUserActive() { return true }
  22. override isAdminActive(id: string) { return id === 'owner' }
  23. override canUseSource() { return true }
  24. }
  25. const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
  26. const mcpFetch: typeof fetch = async (input, init) => {
  27. const url = String(input), body = JSON.parse(String(init?.body || '{}')), method = init?.method ?? 'GET'
  28. requests.push({ url, body, method })
  29. if (url.includes('/tool_router/session') && method === 'POST' && !url.endsWith('/link')) {
  30. const id = 'trs_' + randomUUID(); sessions.set(id, body.user_id)
  31. return response({ session_id: id, config: { user_id: body.user_id }, mcp: { url: `https://backend.composio.dev/tool_router/${id}/mcp` } }, 201)
  32. }
  33. if (url.endsWith('/link')) {
  34. const sessionId = /session\/([^/]+)/.exec(url)![1]!, user = sessions.get(sessionId)
  35. accounts.push({ id: 'ca_' + randomUUID(), user_id: user, toolkit: { slug: body.toolkit }, alias: body.alias, status: 'ACTIVE' })
  36. return response({ redirect_url: 'https://connect.composio.dev/link/fixture' })
  37. }
  38. if (url.includes('/connected_accounts?')) {
  39. if (failedInventory) return response({ error: 'fixture failure' }, 503)
  40. return response({ items: accounts.filter(a => a.user_id === new URL(url).searchParams.get('user_ids')) })
  41. }
  42. if (url.includes('/connected_accounts/') && method === 'DELETE') { const id = new URL(url).pathname.split('/').at(-1); accounts = accounts.filter(a => a.id !== id); return response({ ok: true }) }
  43. if (url.includes('/toolkits?')) return response({ items: [{ slug: 'gmail', name: 'Gmail', meta: { description: '邮件' } }] })
  44. if (body.method === 'notifications/initialized') return new Response(null, { status: 202 })
  45. const result = body.method === 'initialize' ? { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'fixture', version: '1' } }
  46. : body.method === 'tools/list' ? { tools: [{ name: 'fixture_echo', description: 'Echo an explicit value', inputSchema: { type: 'object', properties: { value: { type: 'string' } } } }] }
  47. : { content: [{ type: 'text', text: String(body.params?.arguments?.value) }], structuredContent: { value: body.params?.arguments?.value } }
  48. return response({ jsonrpc: '2.0', id: body.id, result })
  49. }
  50. const api = (method: 'get' | 'post' | 'put' | 'patch' | 'delete', path: string, owner = 'owner') => request(runtime.app.callback())[method]('/api/app/bot-tools' + path)
  51. .set('Host', '127.0.0.1:15300').set('Origin', 'http://127.0.0.1:15300').set('Cookie', cookie).set('x-csrf-token', csrf).set('x-test-user', owner)
  52. beforeEach(async () => {
  53. home = mkdtempSync(join(tmpdir(), 'yaoyao-plugin-test-')); failedInventory = false; requests = []; accounts = []; sessions = new Map()
  54. const config = loadServerConfig({ HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:19119' })
  55. runtime = createApplication({ config, auth: new TestAuth(home, false), pluginFetch: mcpFetch, fetchImpl: (async () => response({ ok: true, auth_required: false, profiles: [] })) as typeof fetch })
  56. const boot = await request(runtime.app.callback()).get('/api/app/bootstrap').set('Host', '127.0.0.1:15300')
  57. cookie = (boot.headers['set-cookie'] as unknown as string[]).map(s => s.split(';')[0]).join('; ')
  58. csrf = boot.body.csrfToken
  59. })
  60. afterEach(async () => { runtime.close(); await new Promise(r => setTimeout(r, 20)); rmSync(home, { recursive: true, force: true }) })
  61. async function addPlugin(agentIds: string[] = []) {
  62. const added = await api('post', '/mcp').send({ name: '测试服务', transport: 'http', url: 'https://mcp.example.test/mcp', headers: { Authorization: 'Bearer fixture-secret' }, agentIds })
  63. expect(added.status, JSON.stringify(added.body)).toBe(201)
  64. return added.body.plugin
  65. }
  66. async function configure(owner = 'owner') {
  67. const result = await api('put', '/settings', owner).send({ apiKey: 'ak_fixture_not_real', revision: 0 })
  68. expect(result.status, JSON.stringify(result.body)).toBe(200)
  69. }
  70. describe('Bot-only plugin management', () => {
  71. it('keeps MCP credentials encrypted and disabled until a successful explicit test', async () => {
  72. const plugin = await addPlugin()
  73. expect(plugin).toMatchObject({ enabled: false, headerKeys: ['Authorization'] }); expect(plugin.testedAt).toBeUndefined()
  74. expect(JSON.stringify(plugin)).not.toContain('fixture-secret')
  75. expect(JSON.stringify(runtime.workspace.list('owner', 'bot-mcp-plugin'))).not.toContain('fixture-secret')
  76. expect((await api('patch', '/mcp/' + plugin.id).send({ enabled: true, revision: 1 })).status).toBe(409)
  77. const tested = await api('post', '/mcp/' + plugin.id + '/test').send({})
  78. expect(tested.status, JSON.stringify(tested.body)).toBe(200); expect(tested.body.plugin.toolCount).toBe(1)
  79. const enabled = await api('patch', '/mcp/' + plugin.id).send({ enabled: true, revision: 1 })
  80. expect(enabled.body.plugin.enabled).toBe(true)
  81. const updated = await api('put', '/mcp/' + plugin.id).send({ revision: 2, definition: { name: '测试服务', transport: 'http', url: plugin.url, headers: { Authorization: true } } })
  82. expect(updated.status).toBe(200); expect(updated.body.plugin.enabled).toBe(false); expect(updated.body.plugin.testedAt).toBeUndefined()
  83. await api('post', '/mcp/' + plugin.id + '/test').send({}).expect(200)
  84. })
  85. it('enforces owner, Bot grants, admin-only MCP and CSRF boundaries', async () => {
  86. const plugin = await addPlugin(), foreign = runtime.workspace.createAgent('member', { name: '其他账号', profile: 'default' })
  87. await api('get', '/mcp', 'member').expect(200).expect(r => expect(r.body.plugins).toEqual([]))
  88. await api('post', '/mcp', 'member').send({ name: 'test', transport: 'stdio', command: process.execPath }).expect(403)
  89. await api('patch', '/mcp/' + plugin.id).send({ agentIds: [foreign.id], revision: 1 }).expect(404)
  90. await api('delete', '/mcp/' + plugin.id, 'member').expect(403)
  91. await request(runtime.app.callback()).post('/api/app/bot-tools/mcp').set('Host', '127.0.0.1:15300').set('Origin', 'http://127.0.0.1:15300').send({}).expect(403)
  92. await api('post', '/mcp').send({ name: 'unsafe', transport: 'http', url: 'file:///etc/passwd' }).expect(400)
  93. await api('post', '/mcp').send({ name: 'unsafe', transport: 'http', url: 'https://mcp.example.test', headers: { Host: 'internal' } }).expect(400)
  94. await api('post', '/mcp').send({ name: 'unsafe', transport: 'stdio', command: process.execPath, env: { constructor: true } }).expect(400)
  95. })
  96. it('tests a real stdio subprocess without inheriting application secrets', async () => {
  97. const code = `require('node:readline').createInterface({input:process.stdin}).on('line',line=>{const r=JSON.parse(line);if(!r.id)return;const result=r.method==='initialize'?{protocolVersion:'2025-06-18',capabilities:{tools:{}}}:{tools:[{name:'environment_check',description:process.env.HERMES_TEST_SECRET?'leaked':'isolated',inputSchema:{type:'object'}}]};process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result})+'\\n')})`
  98. process.env.HERMES_TEST_SECRET = 'do-not-inherit'
  99. try {
  100. const added = await api('post', '/mcp').send({ name: 'stdio fixture', transport: 'stdio', command: process.execPath, args: ['-e', code] })
  101. expect(added.status).toBe(201)
  102. const tested = await api('post', '/mcp/' + added.body.plugin.id + '/test').send({})
  103. expect(tested.status, JSON.stringify(tested.body)).toBe(200)
  104. expect(tested.body.tools[0].description).toBe('isolated')
  105. } finally { delete process.env.HERMES_TEST_SECRET }
  106. })
  107. it('calls an authorized plugin through the native per-turn tool bridge and revokes it immediately', async () => {
  108. const agent = runtime.workspace.createAgent('owner', { name: '插件 Bot', profile: 'default' })
  109. const plugin = await addPlugin([agent.id])
  110. await api('post', '/mcp/' + plugin.id + '/test').send({}).expect(200)
  111. await api('patch', '/mcp/' + plugin.id).send({ enabled: true, revision: 1 }).expect(200)
  112. let binding: any
  113. const target: any = { url: new URL('http://127.0.0.1:19119'), session: { request: async (path: string, options: any) => {
  114. if (path.endsWith('/bind')) binding = options.body
  115. return { status: 200, body: Buffer.from(JSON.stringify(path.endsWith('/capabilities') ? { version: 1, ready: true, native_tools: true, in_process: true } : { ok: true, native_tools: true })) }
  116. } } }
  117. const controller = new AbortController()
  118. const tools = await runtime.workspacePlugins.open('owner', agent, target, controller.signal, () => {})
  119. expect(tools.services()).toEqual([{ name: '测试服务', transport: 'http', toolCount: 1 }])
  120. const originalToolId = tools.catalog()[0]!.id
  121. const lease = await createWorkspaceToolLease({ target, profile: 'default', workId: randomUUID(), signal: controller.signal, session: () => ({ runtimeId: 'live-fixture', storedId: 'stored-fixture' }), assertActive() {}, catalog: () => tools.catalog(), call: (id, args) => tools.call(id, args), onFailure() {} })
  122. try {
  123. await lease.bind()
  124. const bridge = async (path: string, body: unknown) => (await fetch(binding.bridge_url + path, { method: 'POST', headers: { Authorization: 'Bearer ' + binding.token, 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json()
  125. const catalog: any = await bridge('/tools/list', {})
  126. expect(catalog.tools).toHaveLength(1)
  127. expect(JSON.stringify(catalog)).not.toContain('fixture-secret')
  128. const result: any = await bridge('/tools/call', { toolId: catalog.tools[0].id, arguments: { value: 'native bridge verified' }, callId: randomUUID() })
  129. expect(result.structuredContent.value).toBe('native bridge verified')
  130. await api('patch', '/mcp/' + plugin.id).send({ enabled: false, revision: 2 }).expect(200)
  131. expect(tools.catalog()).toEqual([])
  132. expect(tools.services()).toEqual([])
  133. const count = requests.length
  134. await expect(tools.call(originalToolId, {})).rejects.toMatchObject({ code: 'plugin_grant_revoked' })
  135. expect(requests).toHaveLength(count)
  136. } finally { controller.abort(); await tools.dispose(); await lease.dispose() }
  137. const notGranted = runtime.workspace.createAgent('owner', { name: '未授权 Bot', profile: 'default' })
  138. expect(runtime.workspacePlugins.selected('owner', notGranted)).toBe(false)
  139. })
  140. it('runs a server stdio program through the tool bridge without a client filesystem', async () => {
  141. const agent = runtime.workspace.createAgent('owner', { name: '远端客户端使用的 Bot', profile: 'default' })
  142. const script = join(home, 'server-mcp.cjs'), data = join(home, 'server-only.txt')
  143. writeFileSync(data, '服务器文件内容')
  144. writeFileSync(script, `const fs = require('node:fs');
  145. require('node:readline').createInterface({ input: process.stdin }).on('line', line => {
  146. const r = JSON.parse(line); if (!r.id) return;
  147. const result = r.method === 'initialize' ? { protocolVersion: '2025-06-18', capabilities: { tools: {} } }
  148. : r.method === 'tools/list' ? { tools: [{ name: 'read_server_file', inputSchema: { type: 'object' } }] }
  149. : { content: [{ type: 'text', text: fs.readFileSync(process.argv[2], 'utf8') }] };
  150. process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: r.id, result }) + '\\n');
  151. });`)
  152. const added = await api('post', '/mcp').send({ name: '服务器程序', transport: 'stdio', command: process.execPath, args: [script, data], agentIds: [agent.id] }).expect(201)
  153. const id = added.body.plugin.id
  154. await api('post', '/mcp/' + id + '/test').send({}).expect(200)
  155. await api('patch', '/mcp/' + id).send({ enabled: true, revision: 1 }).expect(200)
  156. let binding: any
  157. const target: any = { url: new URL('http://127.0.0.1:19119'), session: { request: async (path: string, options: any) => {
  158. if (path.endsWith('/bind')) binding = options.body
  159. return { status: 200, body: Buffer.from(JSON.stringify(path.endsWith('/capabilities') ? { version: 1, ready: true, native_tools: true, in_process: true } : { ok: true, native_tools: true })) }
  160. } } }
  161. const controller = new AbortController()
  162. const tools = await runtime.workspacePlugins.open('owner', agent, target, controller.signal, () => {})
  163. expect(tools.services()).toEqual([{ name: '服务器程序', transport: 'stdio', toolCount: 1 }])
  164. const lease = await createWorkspaceToolLease({ target, profile: 'default', workId: randomUUID(), signal: controller.signal, session: () => ({ runtimeId: 'server-stdio', storedId: 'server-stdio-stored' }), assertActive() {}, catalog: () => tools.catalog(), call: (id, args) => tools.call(id, args), onFailure() {} })
  165. try {
  166. await lease.bind()
  167. const bridge = async (path: string, body: unknown) => (await fetch(binding.bridge_url + path, { method: 'POST', headers: { Authorization: 'Bearer ' + binding.token, 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json() as Promise<any>
  168. const catalog = await bridge('/tools/list', {})
  169. expect(catalog.tools).toHaveLength(1)
  170. expect(JSON.stringify(catalog)).not.toContain(home)
  171. const result = await bridge('/tools/call', { toolId: catalog.tools[0].id, arguments: {}, callId: randomUUID() })
  172. expect(result.content).toEqual([{ type: 'text', text: '服务器文件内容' }])
  173. } finally { controller.abort(); await tools.dispose(); await lease.dispose() }
  174. })
  175. it('removes deleted Bot grants without changing the service or granting a replacement by name', async () => {
  176. const old = runtime.workspace.createAgent('owner', { name: '小夭', profile: 'default' })
  177. const other = runtime.workspace.createAgent('owner', { name: '保留的 Bot', profile: 'default' })
  178. const plugin = await addPlugin([old.id, other.id])
  179. await api('post', '/mcp/' + plugin.id + '/test').send({}).expect(200)
  180. await api('patch', '/mcp/' + plugin.id).send({ enabled: true, revision: 1 }).expect(200)
  181. const unrelated = await api('post', '/mcp').send({ name: '其他服务', transport: 'http', url: 'https://other.example.test/mcp', agentIds: [other.id] }).expect(201)
  182. const unrelatedBefore = runtime.workspace.require<any>('owner', 'bot-mcp-plugin', unrelated.body.plugin.id)
  183. const before = runtime.workspace.require<any>('owner', 'bot-mcp-plugin', plugin.id)
  184. runtime.workspace.updateAgent('owner', old.id, { archived: true })
  185. runtime.workspace.deleteAgent('owner', old.id)
  186. const after = runtime.workspace.require<any>('owner', 'bot-mcp-plugin', plugin.id)
  187. expect(after).toEqual({ ...before, agentIds: [other.id], revision: before.revision + 1 })
  188. expect(runtime.workspace.require('owner', 'bot-mcp-plugin', unrelated.body.plugin.id)).toEqual(unrelatedBefore)
  189. const replacement = runtime.workspace.createAgent('owner', { name: '小夭', profile: 'default' })
  190. expect(runtime.workspacePlugins.selected('owner', replacement)).toBe(false)
  191. expect(runtime.workspacePlugins.selected('owner', other)).toBe(true)
  192. await api('patch', '/mcp/' + plugin.id).send({ agentIds: [replacement.id, other.id], revision: after.revision }).expect(200)
  193. const rebound = runtime.workspace.require<any>('owner', 'bot-mcp-plugin', plugin.id)
  194. expect(rebound).toMatchObject({ enabled: true, testedAt: before.testedAt, sealed: before.sealed, tools: before.tools })
  195. expect(runtime.workspacePlugins.selected('owner', replacement)).toBe(true)
  196. })
  197. })
  198. describe('account-owned connected applications', () => {
  199. it('supports catalog, OAuth links, multiple accounts, Bot grants and scoped disconnect', async () => {
  200. await configure(); await configure('member')
  201. const ownerSettings = runtime.workspace.list('owner', 'bot-plugin-settings')
  202. expect(JSON.stringify(ownerSettings)).not.toContain('ak_fixture')
  203. await api('get', '/apps/catalog').expect(200).expect(r => expect(r.body.cards[0].slug).toBe('gmail'))
  204. await api('post', '/apps/gmail/authorize').send({}).expect(200)
  205. await api('post', '/apps/gmail/authorize', 'member').send({}).expect(200)
  206. const first = (await api('get', '/apps')).body.connections[0]
  207. await api('post', '/apps/gmail/authorize').send({}).expect(409)
  208. await api('post', '/apps/gmail/authorize').send({ alias: '工作' }).expect(200)
  209. expect((await api('get', '/apps')).body.connections[0].accounts).toHaveLength(2)
  210. const foreign = (await api('get', '/apps', 'member')).body.connections[0].accounts[0].id
  211. await api('delete', '/apps/gmail/accounts/' + foreign).expect(404)
  212. const agent = runtime.workspace.createAgent('owner', { name: '应用 Bot', profile: 'default' })
  213. await api('put', '/apps/gmail/agents').send({ revision: 0, agentIds: [agent.id] }).expect(200)
  214. expect(runtime.workspacePlugins.selected('owner', agent)).toBe(true)
  215. await api('delete', '/apps/gmail/accounts/' + first.accounts[0].id).expect(200)
  216. expect((await api('get', '/apps')).body.connections[0].accounts).toHaveLength(1)
  217. expect((await api('get', '/apps', 'member')).body.connections[0].accounts).toHaveLength(1)
  218. })
  219. it('reports failed inventory reads without falsely reporting all apps disconnected', async () => {
  220. await configure(); await api('post', '/apps/gmail/authorize').send({}).expect(200)
  221. failedInventory = true
  222. const result = await api('get', '/apps')
  223. expect(result.status).toBe(502); expect(result.body.connections).toBeUndefined()
  224. })
  225. it('creates per-Bot toolkit-allowlisted sessions and keeps their URLs and headers server-side', async () => {
  226. await configure()
  227. const id = randomUUID(), signal = new AbortController().signal
  228. const one = await runtime.workspacePlugins.apps.sessionFor('owner', id, ['gmail'], signal)
  229. const two = await runtime.workspacePlugins.apps.sessionFor('owner', id, ['gmail'], signal)
  230. expect(one.url).toBe(two.url)
  231. const created = requests.filter(r => r.url.endsWith('/tool_router/session')).at(-1)!.body
  232. expect(created.toolkits).toEqual({ enable: ['gmail'] })
  233. expect(created.manage_connections.enable).toBe(false)
  234. expect(JSON.stringify(runtime.workspace.list('owner', 'bot-app-session'))).not.toContain(one.url)
  235. await api('put', '/settings').send({ revision: 0, apiKey: 'ak_other' }).expect(409)
  236. })
  237. })
  238. it('accepts SSE MCP responses, preserves tool results and disables redirect following', async () => {
  239. const options: any[] = []
  240. const client = new HttpMcp('https://mcp.example.test/mcp', {}, (async (_url, init) => {
  241. options.push(init); const frame = JSON.parse(String(init?.body))
  242. if (!frame.id) return new Response(null, { status: 202 })
  243. const result = frame.method === 'initialize' ? { protocolVersion: '2025-06-18' } : { content: [{ type: 'text', text: 'SSE result' }], structuredContent: { ok: true } }
  244. return new Response('data: ' + JSON.stringify({ jsonrpc: '2.0', id: frame.id, result }) + '\n\n', { headers: { 'content-type': 'text/event-stream', 'mcp-session-id': 'session-fixture' } })
  245. }) as typeof fetch)
  246. await client.init(); expect((await client.callTool('test', {})).structuredContent).toEqual({ ok: true })
  247. expect(options.every(o => o.redirect === 'error')).toBe(true)
  248. expect(options.at(-1).headers['Mcp-Session-Id']).toBe('session-fixture')
  249. client.dispose()
  250. })