workspacePlugins.test.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. // @vitest-environment node
  2. import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'
  3. import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { createHash, 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. let rejectRequest: ((url: string, body: any, init?: RequestInit) => Response | undefined | Promise<Response | undefined>) | undefined
  17. const principal = (id: string) => ({ id, username: id, role: id === 'owner' ? 'admin' as const : 'user' as const, enabled: true, mustChangePassword: false, createdAt: 1, updatedAt: 1 })
  18. class TestAuth extends LocalAuthStore {
  19. override current(ctx: Koa.Context) { return principal(ctx.get('x-test-user') || 'owner') }
  20. override require(ctx: Koa.Context) { return this.current(ctx) }
  21. override requireAdmin(ctx: Koa.Context) { const user = this.current(ctx); if (user.role !== 'admin') ctx.throw(403); return user }
  22. override isUserActive() { return true }
  23. override isAdminActive(id: string) { return id === 'owner' }
  24. override canUseSource() { return true }
  25. }
  26. const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
  27. const mcpFetch: typeof fetch = async (input, init) => {
  28. const url = String(input), body = JSON.parse(String(init?.body || '{}')), method = init?.method ?? 'GET'
  29. requests.push({ url, body, method })
  30. const rejected = await rejectRequest?.(url, body, init)
  31. if (rejected) return rejected
  32. if (url.includes('/tool_router/session') && method === 'POST' && !url.endsWith('/link')) {
  33. const id = 'trs_' + randomUUID(); sessions.set(id, body.user_id)
  34. return response({ session_id: id, config: { user_id: body.user_id }, mcp: { url: `https://backend.composio.dev/tool_router/${id}/mcp` } }, 201)
  35. }
  36. if (url.endsWith('/link')) {
  37. const sessionId = /session\/([^/]+)/.exec(url)![1]!, user = sessions.get(sessionId)
  38. accounts.push({ id: 'ca_' + randomUUID(), user_id: user, toolkit: { slug: body.toolkit }, alias: body.alias, status: 'ACTIVE' })
  39. return response({ redirect_url: 'https://connect.composio.dev/link/fixture' })
  40. }
  41. if (url.includes('/connected_accounts?')) {
  42. if (failedInventory) return response({ error: 'fixture failure' }, 503)
  43. return response({ items: accounts.filter(a => a.user_id === new URL(url).searchParams.get('user_ids')) })
  44. }
  45. 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 }) }
  46. if (url.includes('/toolkits?')) return response({ items: [{ slug: 'gmail', name: 'Gmail', meta: { description: '邮件' } }] })
  47. if (body.method === 'notifications/initialized') return new Response(null, { status: 202 })
  48. const result = body.method === 'initialize' ? { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'fixture', version: '1' } }
  49. : body.method === 'tools/list' ? { tools: [{ name: 'fixture_echo', description: 'Echo an explicit value', inputSchema: { type: 'object', properties: { value: { type: 'string' } } } }] }
  50. : { content: [{ type: 'text', text: String(body.params?.arguments?.value) }], structuredContent: { value: body.params?.arguments?.value } }
  51. return response({ jsonrpc: '2.0', id: body.id, result })
  52. }
  53. const api = (method: 'get' | 'post' | 'put' | 'patch' | 'delete', path: string, owner = 'owner') => request(runtime.app.callback())[method]('/api/app/bot-tools' + path)
  54. .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)
  55. beforeEach(async () => {
  56. home = mkdtempSync(join(tmpdir(), 'yaoyao-plugin-test-')); failedInventory = false; requests = []; accounts = []; sessions = new Map(); rejectRequest = undefined
  57. const config = loadServerConfig({ HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:19119' })
  58. runtime = createApplication({ config, auth: new TestAuth(home, false), pluginFetch: mcpFetch, fetchImpl: (async () => response({ ok: true, auth_required: false, profiles: [] })) as typeof fetch })
  59. const boot = await request(runtime.app.callback()).get('/api/app/bootstrap').set('Host', '127.0.0.1:15300')
  60. cookie = (boot.headers['set-cookie'] as unknown as string[]).map(s => s.split(';')[0]).join('; ')
  61. csrf = boot.body.csrfToken
  62. })
  63. afterEach(async () => { vi.restoreAllMocks(); runtime.close(); await new Promise(r => setTimeout(r, 20)); rmSync(home, { recursive: true, force: true }) })
  64. async function addPlugin(agentIds: string[] = []) {
  65. const added = await api('post', '/mcp').send({ name: '测试服务', transport: 'http', url: 'https://mcp.example.test/mcp', headers: { Authorization: 'Bearer fixture-secret' }, agentIds })
  66. expect(added.status, JSON.stringify(added.body)).toBe(201)
  67. return added.body.plugin
  68. }
  69. async function configure(owner = 'owner') {
  70. const result = await api('put', '/settings', owner).send({ apiKey: 'ak_fixture_not_real', revision: 0 })
  71. expect(result.status, JSON.stringify(result.body)).toBe(200)
  72. }
  73. describe('Bot-only plugin management', () => {
  74. it('keeps MCP credentials encrypted and disabled until a successful explicit test', async () => {
  75. const plugin = await addPlugin()
  76. expect(plugin).toMatchObject({ enabled: false, headerKeys: ['Authorization'] }); expect(plugin.testedAt).toBeUndefined()
  77. expect(JSON.stringify(plugin)).not.toContain('fixture-secret')
  78. expect(JSON.stringify(runtime.workspace.list('owner', 'bot-mcp-plugin'))).not.toContain('fixture-secret')
  79. expect((await api('patch', '/mcp/' + plugin.id).send({ enabled: true, revision: 1 })).status).toBe(409)
  80. const tested = await api('post', '/mcp/' + plugin.id + '/test').send({})
  81. expect(tested.status, JSON.stringify(tested.body)).toBe(200); expect(tested.body.plugin.toolCount).toBe(1)
  82. const enabled = await api('patch', '/mcp/' + plugin.id).send({ enabled: true, revision: 1 })
  83. expect(enabled.body.plugin.enabled).toBe(true)
  84. const updated = await api('put', '/mcp/' + plugin.id).send({ revision: 2, definition: { name: '测试服务', transport: 'http', url: plugin.url, headers: { Authorization: true } } })
  85. expect(updated.status).toBe(200); expect(updated.body.plugin.enabled).toBe(false); expect(updated.body.plugin.testedAt).toBeUndefined()
  86. await api('post', '/mcp/' + plugin.id + '/test').send({}).expect(200)
  87. })
  88. it('enforces owner, Bot grants, admin-only MCP and CSRF boundaries', async () => {
  89. const plugin = await addPlugin(), foreign = runtime.workspace.createAgent('member', { name: '其他账号', profile: 'default' })
  90. await api('get', '/mcp', 'member').expect(200).expect(r => expect(r.body.plugins).toEqual([]))
  91. await api('post', '/mcp', 'member').send({ name: 'test', transport: 'stdio', command: process.execPath }).expect(403)
  92. await api('patch', '/mcp/' + plugin.id).send({ agentIds: [foreign.id], revision: 1 }).expect(404)
  93. await api('delete', '/mcp/' + plugin.id, 'member').expect(403)
  94. 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)
  95. await api('post', '/mcp').send({ name: 'unsafe', transport: 'http', url: 'file:///etc/passwd' }).expect(400)
  96. await api('post', '/mcp').send({ name: 'unsafe', transport: 'http', url: 'https://mcp.example.test', headers: { Host: 'internal' } }).expect(400)
  97. await api('post', '/mcp').send({ name: 'unsafe', transport: 'stdio', command: process.execPath, env: { constructor: true } }).expect(400)
  98. })
  99. it('tests a real stdio subprocess without inheriting application secrets', async () => {
  100. 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')})`
  101. process.env.HERMES_TEST_SECRET = 'do-not-inherit'
  102. try {
  103. const added = await api('post', '/mcp').send({ name: 'stdio fixture', transport: 'stdio', command: process.execPath, args: ['-e', code] })
  104. expect(added.status).toBe(201)
  105. const tested = await api('post', '/mcp/' + added.body.plugin.id + '/test').send({})
  106. expect(tested.status, JSON.stringify(tested.body)).toBe(200)
  107. expect(tested.body.tools[0].description).toBe('isolated')
  108. } finally { delete process.env.HERMES_TEST_SECRET }
  109. })
  110. it('calls an authorized plugin through the native per-turn tool bridge and revokes it immediately', async () => {
  111. const agent = runtime.workspace.createAgent('owner', { name: '插件 Bot', profile: 'default' })
  112. const plugin = await addPlugin([agent.id])
  113. await api('post', '/mcp/' + plugin.id + '/test').send({}).expect(200)
  114. await api('patch', '/mcp/' + plugin.id).send({ enabled: true, revision: 1 }).expect(200)
  115. let binding: any
  116. const target: any = { url: new URL('http://127.0.0.1:19119'), session: { request: async (path: string, options: any) => {
  117. if (path.endsWith('/bind')) binding = options.body
  118. 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 })) }
  119. } } }
  120. const controller = new AbortController()
  121. const tools = await runtime.workspacePlugins.open('owner', agent, target, controller.signal, () => {})
  122. expect(tools.services()).toEqual([{ name: '测试服务', transport: 'http', toolCount: 1 }])
  123. const originalToolId = tools.catalog()[0]!.id
  124. 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() {} })
  125. try {
  126. await lease.bind()
  127. 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()
  128. const catalog: any = await bridge('/tools/list', {})
  129. expect(catalog.tools).toHaveLength(1)
  130. expect(JSON.stringify(catalog)).not.toContain('fixture-secret')
  131. const result: any = await bridge('/tools/call', { toolId: catalog.tools[0].id, arguments: { value: 'native bridge verified' }, callId: randomUUID() })
  132. expect(result.structuredContent.value).toBe('native bridge verified')
  133. await api('patch', '/mcp/' + plugin.id).send({ enabled: false, revision: 2 }).expect(200)
  134. expect(tools.catalog()).toEqual([])
  135. expect(tools.services()).toEqual([])
  136. const count = requests.length
  137. await expect(tools.call(originalToolId, {})).rejects.toMatchObject({ code: 'plugin_grant_revoked' })
  138. expect(requests).toHaveLength(count)
  139. } finally { controller.abort(); await tools.dispose(); await lease.dispose() }
  140. const notGranted = runtime.workspace.createAgent('owner', { name: '未授权 Bot', profile: 'default' })
  141. expect(runtime.workspacePlugins.selected('owner', notGranted)).toBe(false)
  142. })
  143. it('runs a server stdio program through the tool bridge without a client filesystem', async () => {
  144. const agent = runtime.workspace.createAgent('owner', { name: '远端客户端使用的 Bot', profile: 'default' })
  145. const script = join(home, 'server-mcp.cjs'), data = join(home, 'server-only.txt')
  146. writeFileSync(data, '服务器文件内容')
  147. writeFileSync(script, `const fs = require('node:fs');
  148. require('node:readline').createInterface({ input: process.stdin }).on('line', line => {
  149. const r = JSON.parse(line); if (!r.id) return;
  150. const result = r.method === 'initialize' ? { protocolVersion: '2025-06-18', capabilities: { tools: {} } }
  151. : r.method === 'tools/list' ? { tools: [{ name: 'read_server_file', inputSchema: { type: 'object' } }] }
  152. : { content: [{ type: 'text', text: fs.readFileSync(process.argv[2], 'utf8') }] };
  153. process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: r.id, result }) + '\\n');
  154. });`)
  155. const added = await api('post', '/mcp').send({ name: '服务器程序', transport: 'stdio', command: process.execPath, args: [script, data], agentIds: [agent.id] }).expect(201)
  156. const id = added.body.plugin.id
  157. await api('post', '/mcp/' + id + '/test').send({}).expect(200)
  158. await api('patch', '/mcp/' + id).send({ enabled: true, revision: 1 }).expect(200)
  159. let binding: any
  160. const target: any = { url: new URL('http://127.0.0.1:19119'), session: { request: async (path: string, options: any) => {
  161. if (path.endsWith('/bind')) binding = options.body
  162. 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 })) }
  163. } } }
  164. const controller = new AbortController()
  165. const tools = await runtime.workspacePlugins.open('owner', agent, target, controller.signal, () => {})
  166. expect(tools.services()).toEqual([{ name: '服务器程序', transport: 'stdio', toolCount: 1 }])
  167. 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() {} })
  168. try {
  169. await lease.bind()
  170. 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>
  171. const catalog = await bridge('/tools/list', {})
  172. expect(catalog.tools).toHaveLength(1)
  173. expect(JSON.stringify(catalog)).not.toContain(home)
  174. const result = await bridge('/tools/call', { toolId: catalog.tools[0].id, arguments: {}, callId: randomUUID() })
  175. expect(result.content).toEqual([{ type: 'text', text: '服务器文件内容' }])
  176. } finally { controller.abort(); await tools.dispose(); await lease.dispose() }
  177. })
  178. it('removes deleted Bot grants without changing the service or granting a replacement by name', async () => {
  179. const old = runtime.workspace.createAgent('owner', { name: '小夭', profile: 'default' })
  180. const other = runtime.workspace.createAgent('owner', { name: '保留的 Bot', profile: 'default' })
  181. const plugin = await addPlugin([old.id, other.id])
  182. await api('post', '/mcp/' + plugin.id + '/test').send({}).expect(200)
  183. await api('patch', '/mcp/' + plugin.id).send({ enabled: true, revision: 1 }).expect(200)
  184. const unrelated = await api('post', '/mcp').send({ name: '其他服务', transport: 'http', url: 'https://other.example.test/mcp', agentIds: [other.id] }).expect(201)
  185. const unrelatedBefore = runtime.workspace.require<any>('owner', 'bot-mcp-plugin', unrelated.body.plugin.id)
  186. const before = runtime.workspace.require<any>('owner', 'bot-mcp-plugin', plugin.id)
  187. runtime.workspace.updateAgent('owner', old.id, { archived: true })
  188. runtime.workspace.deleteAgent('owner', old.id)
  189. const after = runtime.workspace.require<any>('owner', 'bot-mcp-plugin', plugin.id)
  190. expect(after).toEqual({ ...before, agentIds: [other.id], revision: before.revision + 1 })
  191. expect(runtime.workspace.require('owner', 'bot-mcp-plugin', unrelated.body.plugin.id)).toEqual(unrelatedBefore)
  192. const replacement = runtime.workspace.createAgent('owner', { name: '小夭', profile: 'default' })
  193. expect(runtime.workspacePlugins.selected('owner', replacement)).toBe(false)
  194. expect(runtime.workspacePlugins.selected('owner', other)).toBe(true)
  195. await api('patch', '/mcp/' + plugin.id).send({ agentIds: [replacement.id, other.id], revision: after.revision }).expect(200)
  196. const rebound = runtime.workspace.require<any>('owner', 'bot-mcp-plugin', plugin.id)
  197. expect(rebound).toMatchObject({ enabled: true, testedAt: before.testedAt, sealed: before.sealed, tools: before.tools })
  198. expect(runtime.workspacePlugins.selected('owner', replacement)).toBe(true)
  199. })
  200. })
  201. describe('account-owned connected applications', () => {
  202. it('supports catalog, OAuth links, multiple accounts, Bot grants and scoped disconnect', async () => {
  203. await configure(); await configure('member')
  204. const ownerSettings = runtime.workspace.list('owner', 'bot-plugin-settings')
  205. expect(JSON.stringify(ownerSettings)).not.toContain('ak_fixture')
  206. await api('get', '/apps/catalog').expect(200).expect(r => expect(r.body.cards[0].slug).toBe('gmail'))
  207. await api('post', '/apps/gmail/authorize').send({}).expect(200)
  208. await api('post', '/apps/gmail/authorize', 'member').send({}).expect(200)
  209. const first = (await api('get', '/apps')).body.connections[0]
  210. await api('post', '/apps/gmail/authorize').send({}).expect(409)
  211. await api('post', '/apps/gmail/authorize').send({ alias: '工作' }).expect(200)
  212. expect((await api('get', '/apps')).body.connections[0].accounts).toHaveLength(2)
  213. const foreign = (await api('get', '/apps', 'member')).body.connections[0].accounts[0].id
  214. await api('delete', '/apps/gmail/accounts/' + foreign).expect(404)
  215. const agent = runtime.workspace.createAgent('owner', { name: '应用 Bot', profile: 'default' })
  216. await api('put', '/apps/gmail/agents').send({ revision: 0, agentIds: [agent.id] }).expect(200)
  217. expect(runtime.workspacePlugins.selected('owner', agent)).toBe(true)
  218. await api('delete', '/apps/gmail/accounts/' + first.accounts[0].id).expect(200)
  219. expect((await api('get', '/apps')).body.connections[0].accounts).toHaveLength(1)
  220. expect((await api('get', '/apps', 'member')).body.connections[0].accounts).toHaveLength(1)
  221. })
  222. it('reports failed inventory reads without falsely reporting all apps disconnected', async () => {
  223. await configure(); await api('post', '/apps/gmail/authorize').send({}).expect(200)
  224. failedInventory = true
  225. const result = await api('get', '/apps')
  226. expect(result.status).toBe(502); expect(result.body.connections).toBeUndefined()
  227. })
  228. it('creates per-Bot toolkit-allowlisted sessions and keeps their URLs and headers server-side', async () => {
  229. await configure()
  230. const id = randomUUID(), signal = new AbortController().signal
  231. const one = await runtime.workspacePlugins.apps.sessionFor('owner', id, ['gmail'], signal)
  232. const two = await runtime.workspacePlugins.apps.sessionFor('owner', id, ['gmail'], signal)
  233. expect(one.url).toBe(two.url)
  234. const created = requests.filter(r => r.url.endsWith('/tool_router/session')).at(-1)!.body
  235. expect(created.toolkits).toEqual({ enable: ['gmail'] })
  236. expect(created.manage_connections.enable).toBe(false)
  237. expect(JSON.stringify(runtime.workspace.list('owner', 'bot-app-session'))).not.toContain(one.url)
  238. await api('put', '/settings').send({ revision: 0, apiKey: 'ak_other' }).expect(409)
  239. })
  240. })
  241. it('accepts SSE MCP responses, preserves tool results and disables redirect following', async () => {
  242. const options: any[] = []
  243. const client = new HttpMcp('https://mcp.example.test/mcp', {}, (async (_url, init) => {
  244. options.push(init); const frame = JSON.parse(String(init?.body))
  245. if (!frame.id) return new Response(null, { status: 202 })
  246. const result = frame.method === 'initialize' ? { protocolVersion: '2025-06-18' } : { content: [{ type: 'text', text: 'SSE result' }], structuredContent: { ok: true } }
  247. 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' } })
  248. }) as typeof fetch)
  249. await client.init(); expect((await client.callTool('test', {})).structuredContent).toEqual({ ok: true })
  250. expect(options.every(o => o.redirect === 'error')).toBe(true)
  251. expect(options.at(-1).headers['Mcp-Session-Id']).toBe('session-fixture')
  252. client.dispose()
  253. })
  254. const pluginTarget: any = { url: new URL('http://127.0.0.1:19119'), session: { request: async () => ({
  255. status: 200, body: Buffer.from(JSON.stringify({ version: 1, ready: true, native_tools: true, in_process: true })),
  256. }) } }
  257. async function enablePlugin(agentId: string, name: string, url: string) {
  258. const added = await api('post', '/mcp').send({ name, transport: 'http', url, agentIds: [agentId] }).expect(201)
  259. await api('post', '/mcp/' + added.body.plugin.id + '/test').send({}).expect(200)
  260. await api('patch', '/mcp/' + added.body.plugin.id).send({ enabled: true, revision: 1 }).expect(200)
  261. return added.body.plugin.id as string
  262. }
  263. it.each(['initialize', 'tools/list'])('isolates a failed %s without discarding healthy MCP services or claiming failed tools worked', async method => {
  264. const agent = runtime.workspace.createAgent('owner', { name: '可选插件 Bot', profile: 'default' })
  265. const badId = await enablePlugin(agent.id, '离线服务', 'https://offline.example.test/mcp')
  266. await enablePlugin(agent.id, '健康服务', 'https://healthy.example.test/mcp')
  267. rejectRequest = (url, body) => url.includes('offline.example.test') && body.method === method ? response({ error: 'Bearer fixture-secret' }, 503) : undefined
  268. const disposed = vi.spyOn(HttpMcp.prototype, 'dispose')
  269. const lease = await runtime.workspacePlugins.open('owner', agent, pluginTarget, new AbortController().signal, () => {})
  270. try {
  271. expect(lease.services()).toEqual([{ name: '健康服务', transport: 'http', toolCount: 1 }])
  272. expect(lease.warnings()).toEqual([{ name: '离线服务', code: 'plugin_initialization_failed', message: expect.stringContaining('不可用') }])
  273. expect(JSON.stringify(lease.warnings())).not.toContain('fixture-secret')
  274. expect(lease.catalog()).toHaveLength(1)
  275. expect(lease.catalog()[0]!.description).toContain('健康服务')
  276. expect((await lease.call(lease.catalog()[0]!.id, { value: 'healthy' }) as any).structuredContent.value).toBe('healthy')
  277. const count = requests.length, failedTool = 'plugin_' + createHash('sha256').update(badId + ':fixture_echo').digest('hex').slice(0, 32)
  278. await expect(lease.call(failedTool, {})).rejects.toMatchObject({ code: 'plugin_unavailable' })
  279. expect(requests).toHaveLength(count)
  280. expect(disposed).toHaveBeenCalledOnce()
  281. await api('patch', '/mcp/' + badId).send({ enabled: false, revision: 2 }).expect(200)
  282. expect(lease.warnings()).toEqual([])
  283. await expect(lease.call(failedTool, {})).rejects.toMatchObject({ code: 'plugin_grant_revoked' })
  284. } finally { await lease.dispose() }
  285. })
  286. it('returns an unavailable-app warning when connected-app session creation fails, preserving healthy MCP tools', async () => {
  287. const agent = runtime.workspace.createAgent('owner', { name: '应用故障隔离', profile: 'default' })
  288. await enablePlugin(agent.id, '健康服务', 'https://healthy.example.test/mcp')
  289. await configure()
  290. await api('put', '/apps/gmail/agents').send({ revision: 0, agentIds: [agent.id] }).expect(200)
  291. rejectRequest = url => url.endsWith('/tool_router/session') ? response({ error: 'upstream unavailable' }, 503) : undefined
  292. const lease = await runtime.workspacePlugins.open('owner', agent, pluginTarget, new AbortController().signal, () => {})
  293. try {
  294. expect(lease.services()).toEqual([{ name: '健康服务', transport: 'http', toolCount: 1 }])
  295. expect(lease.warnings()).toEqual([{ name: '已连接应用', code: 'plugin_initialization_failed', message: expect.stringContaining('不可用') }])
  296. expect(lease.catalog()).toHaveLength(1)
  297. expect((await lease.call(lease.catalog()[0]!.id, { value: 'still connected' }) as any).structuredContent.value).toBe('still connected')
  298. } finally { await lease.dispose() }
  299. })
  300. it('returns an empty, explicitly degraded lease when every optional plugin is offline', async () => {
  301. const agent = runtime.workspace.createAgent('owner', { name: '普通聊天', profile: 'default' })
  302. await enablePlugin(agent.id, '离线服务', 'https://offline.example.test/mcp')
  303. rejectRequest = (_url, body) => body.method === 'initialize' ? response({}, 503) : undefined
  304. const lease = await runtime.workspacePlugins.open('owner', agent, pluginTarget, new AbortController().signal, () => {})
  305. try {
  306. expect(lease.services()).toEqual([]); expect(lease.catalog()).toEqual([])
  307. expect(lease.warnings()).toHaveLength(1)
  308. await expect(lease.call('plugin_unknown', {})).rejects.toMatchObject({ code: 'plugin_tool_not_found' })
  309. } finally { await lease.dispose() }
  310. })
  311. it('does not turn cancellation or a revoked plugin grant into a degraded lease', async () => {
  312. const agent = runtime.workspace.createAgent('owner', { name: '撤权 Bot', profile: 'default' })
  313. const id = await enablePlugin(agent.id, '受控服务', 'https://controlled.example.test/mcp')
  314. const controller = new AbortController()
  315. rejectRequest = (_url, body) => {
  316. if (body.method === 'initialize') { controller.abort(); throw new Error('cancelled transport') }
  317. return undefined
  318. }
  319. await expect(runtime.workspacePlugins.open('owner', agent, pluginTarget, controller.signal, () => {})).rejects.toMatchObject({ code: 'plugin_grant_revoked' })
  320. rejectRequest = (_url, body) => {
  321. if (body.method === 'initialize') {
  322. const current = runtime.workspace.require<any>('owner', 'bot-mcp-plugin', id)
  323. runtime.workspace.put('owner', 'bot-mcp-plugin', id, { ...current, enabled: false, revision: current.revision + 1 })
  324. return response({}, 503)
  325. }
  326. return undefined
  327. }
  328. await expect(runtime.workspacePlugins.open('owner', agent, pluginTarget, new AbortController().signal, () => {})).rejects.toMatchObject({ code: 'plugin_grant_revoked' })
  329. })
  330. it('reaps a failed stdio initialization process while keeping a healthy service available', async () => {
  331. const agent = runtime.workspace.createAgent('owner', { name: '进程故障隔离', profile: 'default' })
  332. const script = join(home, 'degraded-plugin.cjs'), pidFile = join(home, 'degraded-plugin.pid'), failFile = join(home, 'fail-list')
  333. writeFileSync(script, `const fs=require('node:fs');fs.writeFileSync(process.argv[2],String(process.pid));
  334. require('node:readline').createInterface({input:process.stdin}).on('line',line=>{
  335. const r=JSON.parse(line);if(!r.id)return;
  336. const result=r.method==='initialize'?{protocolVersion:'2025-06-18',capabilities:{tools:{}}}
  337. :{tools:fs.existsSync(process.argv[3])?[{name:'duplicate'},{name:'duplicate'}]:[{name:'stdio_tool',inputSchema:{type:'object'}}]};
  338. process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result})+'\\n');
  339. });`)
  340. const added = await api('post', '/mcp').send({ name: '故障进程', transport: 'stdio', command: process.execPath, args: [script, pidFile, failFile], agentIds: [agent.id] }).expect(201)
  341. await api('post', '/mcp/' + added.body.plugin.id + '/test').send({}).expect(200)
  342. await api('patch', '/mcp/' + added.body.plugin.id).send({ enabled: true, revision: 1 }).expect(200)
  343. await enablePlugin(agent.id, '健康服务', 'https://healthy.example.test/mcp')
  344. writeFileSync(failFile, 'fail')
  345. const lease = await runtime.workspacePlugins.open('owner', agent, pluginTarget, new AbortController().signal, () => {})
  346. try {
  347. expect(lease.services()).toEqual([{ name: '健康服务', transport: 'http', toolCount: 1 }])
  348. expect(lease.warnings()[0]).toMatchObject({ name: '故障进程', code: 'plugin_initialization_failed' })
  349. const pid = Number(readFileSync(pidFile, 'utf8'))
  350. expect(pid).toBeGreaterThan(0)
  351. expect(() => process.kill(pid, 0)).toThrow()
  352. } finally { await lease.dispose() }
  353. })