workspaceToolLease.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
  2. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
  3. import { z } from 'zod'
  4. import { HttpError } from './errors.js'
  5. import { isLoopbackHost } from './config.js'
  6. import { parse } from './workspaceStore.js'
  7. import type { GatewayTarget } from './workspaceGateway.js'
  8. const PREFIX = '/api/plugins/yaoyao-bot-bridge'
  9. const TTL = 30 * 60_000
  10. const envelope = z.object({ toolId: z.string().min(1).max(100), arguments: z.record(z.string(), z.unknown()), callId: z.string().min(1).max(200) }).strict()
  11. type Catalog = Array<{ id: string; name: string; description: string; inputSchema: unknown }>
  12. async function bridgeRequest(target: GatewayTarget, path: string, body: unknown) {
  13. const response = await target.session.request(PREFIX + path, {
  14. method: 'POST', body,
  15. })
  16. let value: Record<string, unknown> = {}
  17. try { value = JSON.parse(response.body.toString()) } catch { /* report a bounded, credential-free error */ }
  18. if (response.status !== 200 || value.ok !== true)
  19. throw new HttpError(409, 'Hermes 工具桥绑定失败,请确认该 Profile 已启用工具桥,并新建会话后重试。', 'team_tools_bind_failed')
  20. return value
  21. }
  22. export async function requireTeamToolBridge(target: GatewayTarget, profile: string): Promise<void> {
  23. if (!target.runner && (target.pairedToken || !isLoopbackHost(target.url.hostname)))
  24. throw new HttpError(409, '组队发起者的 Hermes 必须与 Web 服务运行在同一台机器,并使用回环地址连接。', 'team_tools_local_required')
  25. let ready = false
  26. try {
  27. const response = await target.session.request(PREFIX + '/capabilities', { search: new URLSearchParams({ profile }), cache: 'reload' })
  28. const value = JSON.parse(response.body.toString())
  29. ready = response.status === 200 && value.version === 1 && value.ready === true && value.in_process === true && value.native_tools === true
  30. } catch { /* Missing or incompatible plugins do not break ordinary Bot conversations. */ }
  31. if (!ready) throw new HttpError(409, '该基础机器人尚未启用新版 Hermes 工具桥(yaoyao-bot-bridge)。请在对应 Profile 安装或修复并重新加载后,再开启组队权限。', 'team_tools_unavailable')
  32. }
  33. export interface WorkspaceToolLease {
  34. bind(): Promise<void>
  35. dispose(): Promise<void>
  36. }
  37. export interface LeaseInput {
  38. target: GatewayTarget
  39. profile: string
  40. workId: string
  41. session: () => { runtimeId: string; storedId: string }
  42. signal: AbortSignal
  43. assertActive(): void
  44. catalog(): Catalog
  45. call(toolId: string, args: unknown, callId?:string): Promise<unknown>
  46. onFailure(error: Error): void
  47. }
  48. /** A private loopback listener; no browser credentials or authority in prompts. */
  49. export async function createWorkspaceToolLease(input: LeaseInput): Promise<WorkspaceToolLease> {
  50. if(input.target.runner)return input.target.runner.lease(input)
  51. const token = randomBytes(32).toString('base64url'), tokenHash = createHash('sha256').update(token).digest()
  52. const generation = `${input.workId}:${randomUUID()}`
  53. let expiresAt = Date.now() + TTL, closed = false, url = ''
  54. let timer: ReturnType<typeof setInterval> | undefined
  55. let binding: Promise<void> | undefined, cleanup: Promise<void> | undefined
  56. let boundRuntimeId = ''
  57. let bound = false
  58. const calls = new Map<string, { fingerprint: string; result: Promise<unknown> }>()
  59. // The Hermes registry is shared: IDs must differ between simultaneous grants
  60. // so each session gets distinct native function names and bound handlers.
  61. const opaqueId = (id: string) => `team_${createHash('sha256').update(`${token}:${id}`).digest('hex').slice(0,32)}`
  62. const server = createServer((req, res) => { void handle(req, res).catch(error => {
  63. json(res, error instanceof HttpError ? error.status : 500, {
  64. error: error instanceof HttpError ? error.message : '团队工具执行失败',
  65. code: error instanceof HttpError ? error.code : 'team_tool_failed',
  66. })
  67. }) })
  68. server.requestTimeout = 15_000
  69. server.headersTimeout = 10_000
  70. server.maxHeadersCount = 30
  71. server.keepAliveTimeout = 1000
  72. server.on('clientError', (_error, socket) => socket.destroy())
  73. function assertActive() {
  74. if (closed || input.signal.aborted || Date.now() >= expiresAt)
  75. throw new HttpError(410, '本轮组队授权已失效', 'team_tools_expired')
  76. input.assertActive()
  77. }
  78. function json(res: ServerResponse, status: number, value: unknown) {
  79. if (res.destroyed || res.writableEnded) return
  80. res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' })
  81. res.end(JSON.stringify(value))
  82. }
  83. async function read(req: IncomingMessage): Promise<unknown> {
  84. const chunks: Buffer[] = []; let size = 0
  85. for await (const chunk of req) {
  86. size += chunk.length
  87. if (size > 256 * 1024) throw new HttpError(413, '团队工具参数过大', 'team_tool_input_limit')
  88. chunks.push(Buffer.from(chunk))
  89. }
  90. try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') }
  91. catch { throw new HttpError(400, '工具请求必须是有效 JSON', 'invalid_json') }
  92. }
  93. async function handle(req: IncomingMessage, res: ServerResponse) {
  94. if (!url || req.socket.remoteAddress !== '127.0.0.1' || req.headers.host !== new URL(url).host || req.headers.origin !== undefined)
  95. throw new HttpError(403, '工具接口只接受本机可信连接', 'team_tools_forbidden')
  96. const provided = (req.headers.authorization ?? '').replace(/^Bearer /, '')
  97. if (!req.headers.authorization?.startsWith('Bearer ') || !timingSafeEqual(tokenHash, createHash('sha256').update(provided).digest()))
  98. throw new HttpError(401, '本轮工具凭据无效', 'team_tools_unauthorized')
  99. assertActive()
  100. if (req.method !== 'POST') throw new HttpError(405, '工具接口只接受 POST', 'method_not_allowed')
  101. if (req.headers['content-type']?.split(';')[0]?.trim() !== 'application/json')
  102. throw new HttpError(415, '工具接口只接受 JSON', 'invalid_content_type')
  103. const body = await read(req)
  104. assertActive()
  105. if (req.url === '/tools/list') {
  106. parse(z.object({}).strict(), body)
  107. json(res, 200, { tools: input.catalog().map(tool => ({ ...tool, id: opaqueId(tool.id) })), warnings: [] })
  108. return
  109. }
  110. if (req.url !== '/tools/call') throw new HttpError(404, '工具接口不存在', 'not_found')
  111. const call = parse(envelope, body)
  112. const tool = input.catalog().find(candidate => opaqueId(candidate.id) === call.toolId)
  113. if (!tool) throw new HttpError(404, '本轮未授权此工具', 'team_tool_not_found')
  114. const fingerprint = JSON.stringify(canonical([call.toolId, call.arguments]))
  115. let previous = calls.get(call.callId)
  116. if (previous && previous.fingerprint !== fingerprint)
  117. throw new HttpError(409, '同一调用编号不能用于不同操作', 'idempotency_conflict')
  118. if (!previous) {
  119. if (calls.size >= 128) throw new HttpError(429, '本轮团队工具调用已达上限,请在后续对话继续。', 'team_tool_call_limit')
  120. const result = Promise.resolve().then(async () => {
  121. assertActive()
  122. try {
  123. const value = await input.call(tool.id, call.arguments, call.callId)
  124. if(value&&typeof value==='object'&&Array.isArray((value as any).content))return value
  125. return { content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value }
  126. } catch (error) {
  127. return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof HttpError ? error.message : '团队操作失败,请检查当前状态后再重试。', code: error instanceof HttpError ? error.code : 'team_tool_failed' }) }], isError: true }
  128. }
  129. })
  130. previous = { fingerprint, result }; calls.set(call.callId, previous)
  131. }
  132. const result = await previous.result
  133. assertActive()
  134. json(res, 200, result)
  135. }
  136. function dispose(): Promise<void> {
  137. if (cleanup) return cleanup
  138. closed = true
  139. clearInterval(timer)
  140. input.signal.removeEventListener('abort', abort)
  141. server.closeAllConnections()
  142. cleanup = (async () => {
  143. await new Promise<void>(resolve => { if (!server.listening) resolve(); else server.close(() => resolve()) })
  144. await binding?.catch(() => {})
  145. if (boundRuntimeId) await bridgeRequest(input.target, '/unbind', { session_id: boundRuntimeId, generation }).catch(() => {})
  146. calls.clear()
  147. })()
  148. return cleanup
  149. }
  150. const abort = () => { void dispose() }
  151. async function bind() {
  152. assertActive()
  153. if (binding) return binding
  154. const session = input.session()
  155. boundRuntimeId = session.runtimeId
  156. binding = (async () => {
  157. const bindSession = async () => {
  158. const current = input.session()
  159. const result = await bridgeRequest(input.target, '/bind', { native_tools: true, session_id: current.runtimeId,
  160. stored_session_id: current.storedId, profile: input.profile, generation, bridge_url: url, token, expires_at: expiresAt })
  161. if (!bound && result.native_tools !== true)
  162. throw new HttpError(409, 'Hermes 未挂载本轮原生团队工具,请升级或修复工具桥。', 'team_tools_unavailable')
  163. bound = true
  164. }
  165. try { await bindSession() }
  166. catch (error) {
  167. if (input.session().storedId === session.storedId || closed) throw error
  168. await bindSession()
  169. }
  170. assertActive()
  171. })()
  172. try { await binding }
  173. finally { binding = undefined }
  174. }
  175. try {
  176. await new Promise<void>((resolve, reject) => {
  177. server.once('error', reject)
  178. server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve() })
  179. })
  180. const address = server.address()
  181. if (!address || typeof address === 'string') throw new Error('无法启动团队工具接口')
  182. url = `http://127.0.0.1:${address.port}`
  183. input.signal.addEventListener('abort', abort, { once: true })
  184. assertActive()
  185. timer = setInterval(() => {
  186. if (binding) return
  187. try { assertActive() } catch (error) { void dispose(); input.onFailure(error as Error); return }
  188. expiresAt = Date.now() + TTL
  189. void bind().catch(error => { void dispose(); input.onFailure(error as Error) })
  190. }, 5 * 60_000)
  191. timer.unref()
  192. return { bind, dispose }
  193. } catch (error) { await dispose(); throw error }
  194. }
  195. function canonical(value: unknown, depth = 0): unknown {
  196. if (depth > 30) throw new HttpError(400, '工具参数嵌套过深', 'invalid_workspace_request')
  197. if (Array.isArray(value)) return value.map(item => canonical(item, depth + 1))
  198. if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item, depth + 1)]))
  199. return value
  200. }