workspaceRemoteAgents.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. import type Koa from 'koa'
  2. import { Readable } from 'node:stream'
  3. import { HttpError } from './errors.js'
  4. import type { LocalAuthStore } from './localAuth.js'
  5. import type { NodePairingStore } from './pairing.js'
  6. import type { WorkspaceStore } from './workspaceStore.js'
  7. import type { WorkspaceNodes, GatewayTarget } from './workspaceGateway.js'
  8. import type { WorkspaceAgent } from '../shared/workspace.js'
  9. export interface AgentExportContext {
  10. id: string
  11. target: GatewayTarget
  12. valid(): boolean
  13. transform(frame: Record<string, any>): Record<string, any>
  14. }
  15. const allowedCommands = new Set(['session.create','session.resume','session.close','session.usage','session.interrupt','prompt.submit','session.steer','image.attach_bytes','file.attach','approval.respond','clarify.respond'])
  16. /** A paired device sees only Agents owned by the account that issued its QR grant. */
  17. export function remoteAgentExports(store: WorkspaceStore, nodes: WorkspaceNodes, pairings: NodePairingStore, auth: LocalAuthStore): Koa.Middleware {
  18. return async (ctx,next) => {
  19. const match = /^\/node\/([0-9a-f-]{36})\/api\/workspace-agents(?:\/([0-9a-f-]{36})(?:\/gateway(\/api\/.*))?)?$/.exec(ctx.path)
  20. if (!match) return next()
  21. const device=match[1]!, agentId=match[2], path=match[3], token=ctx.get('authorization').match(/^Bearer\s+(.+)$/i)?.[1] ?? ''
  22. const owner=pairings.workspaceOwner(device,token,path ? 'sessions.execute' : 'agents.read')
  23. if (!auth.isUserActive(owner)) throw new HttpError(403,'远端授权账号已停用','remote_owner_unavailable')
  24. const valid = () => {
  25. try { return pairings.workspaceOwner(device,token,'sessions.execute')===owner && auth.isUserActive(owner)
  26. && (!agentId || store.get<WorkspaceAgent>(owner,'agent',agentId)?.archived === false) }
  27. catch { return false }
  28. }
  29. ctx.set('Cache-Control','no-store')
  30. if (!agentId) {
  31. if (ctx.method!=='GET') throw new HttpError(405,'只允许读取远端机器人','method_not_allowed')
  32. const agents=store.list<WorkspaceAgent>(owner,'agent').filter(a=>!a.archived)
  33. for(const value of agents) if(value.remoteAgentId) void nodes.refreshRemoteAgent(owner,value).catch(()=>{})
  34. ctx.body={agents:agents.map(a=>store.agentSummary(a))};return
  35. }
  36. let agent=store.require<WorkspaceAgent>(owner,'agent',agentId)
  37. if (agent.archived) throw new HttpError(410,'远端机器人已归档','remote_agent_archived')
  38. if (agent.remoteAgentId) agent=await nodes.refreshRemoteAgent(owner,agent)
  39. if (!path) {
  40. if (ctx.method!=='GET') throw new HttpError(405,'远端配置只读','method_not_allowed')
  41. ctx.body={agent:store.agentSummary(agent)};return
  42. }
  43. const hops=Number(ctx.get('x-yaoyao-agent-hops')||0)
  44. if (!Number.isInteger(hops) || hops<0 || hops>=8) throw new HttpError(508,'远端机器人引用链过长或循环','agent_reference_loop')
  45. const target=nodes.targetForAgent(owner,agent), scope=`${device}:${agentId}`
  46. const bindingKey=(id:string)=>`${scope}:${id}`
  47. const requireBinding=(id:string)=>{
  48. const binding=store.get<{profile:string}>(owner,'agent-export-binding',bindingKey(id))
  49. if (!binding || binding.profile!==agent.profile) throw new HttpError(403,'会话不属于这个机器人引用','agent_session_forbidden')
  50. }
  51. const transform=(frame:Record<string,any>)=>{
  52. if (!allowedCommands.has(frame.method)) throw new HttpError(403,'不能通过引用修改远端配置','remote_agent_read_only')
  53. const params={...frame.params}
  54. if (frame.method==='session.create' || frame.method==='session.resume') {
  55. if(frame.method==='session.resume') requireBinding(String(params.session_id))
  56. params.profile=agent.profile
  57. for (const key of ['model','provider','config','cwd','reasoning_effort','fast','messages']) delete params[key]
  58. }
  59. if ((frame.method==='prompt.submit'||frame.method==='session.steer') && !agent.remoteAgentId) {
  60. if (typeof params.text!=='string') throw new HttpError(400,'消息内容无效','invalid_prompt')
  61. params.text=`你是 ${agent.name}。远端机器人的角色与规则(版本 ${agent.revision}):\n${agent.instructions}\n\n${params.text}`
  62. }
  63. const id=ctx.get('idempotency-key')
  64. if (id) store.put(owner,'agent-export-command',bindingKey(id),{method:frame.method,profile:agent.profile})
  65. return {...frame,params}
  66. }
  67. const remember=(receipt:any)=>{
  68. const id=ctx.get('idempotency-key') || /^\/api\/realtime\/commands\/([^/]+)$/.exec(path)?.[1]
  69. const command=id ? store.get<{method:string}>(owner,'agent-export-command',bindingKey(id)) : undefined
  70. if (!id || !command || !['session.create','session.resume'].includes(command.method)) return
  71. const result=receipt?.state==='confirmed' ? receipt.response?.result : undefined
  72. const stored=result?.stored_session_id ?? result?.session_key ?? result?.info?.stored_session_id ?? result?.session_id
  73. if (typeof stored==='string' && stored.length<=256) {
  74. store.put(owner,'agent-export-binding',bindingKey(stored),{profile:agent.profile})
  75. }
  76. }
  77. if (path==='/api/profiles' && ctx.method==='GET') { ctx.body={profiles:[{name:agent.profile,display_name:agent.name}]};return }
  78. if (path.startsWith('/api/realtime/')) {
  79. const selectedChannel=/^\/api\/realtime\/channels\/([0-9a-f-]{36})/.exec(path)?.[1]
  80. if (selectedChannel && !store.get(owner,'agent-export-channel',bindingKey(selectedChannel)))
  81. throw new HttpError(403,'通道不属于这个机器人引用','agent_channel_forbidden')
  82. const receiptID=/^\/api\/realtime\/commands\/([^/]+)$/.exec(path)?.[1]
  83. if (receiptID && !store.get(owner,'agent-export-command',bindingKey(receiptID)))
  84. throw new HttpError(403,'命令不属于这个机器人引用','agent_command_forbidden')
  85. const rememberChannel=(body:any)=>{
  86. if(ctx.method==='POST' && path==='/api/realtime/channels' && typeof body?.id==='string')
  87. store.put(owner,'agent-export-channel',bindingKey(body.id),{createdAt:Date.now()})
  88. }
  89. if (!target.pairedToken) {
  90. ctx.state.workspaceAgentExport={id:agentId,target,valid,transform} satisfies AgentExportContext
  91. const previous=ctx.url
  92. ctx.url=`/node/${device}${path}${ctx.search}`
  93. try { await next(); rememberChannel(ctx.body); remember(ctx.body) } finally { ctx.url=previous }
  94. return
  95. }
  96. const allowed=/^\/api\/realtime\/(capabilities|channels(?:\/[0-9a-f-]{36}(?:\/(events|commands))?)?|commands\/[A-Za-z0-9:_-]{1,200})$/.test(path)
  97. if (!allowed) throw new HttpError(404,'机器人实时路径不存在','not_found')
  98. let body: any
  99. if (ctx.method==='POST') {
  100. let size=0;const parts:Buffer[]=[]
  101. for await(const part of ctx.req) {size+=part.length;if(size>36*1024*1024) throw new HttpError(413,'请求过大','body_too_large');parts.push(Buffer.from(part))}
  102. try {body=JSON.parse(Buffer.concat(parts).toString())} catch {throw new HttpError(400,'JSON 无效','invalid_json')}
  103. if(path.endsWith('/commands')) body=transform(body)
  104. else if(body.channel!=='chat') throw new HttpError(403,'机器人仅支持聊天通道','invalid_channel')
  105. }
  106. const abort=new AbortController(), timer=setTimeout(()=>abort.abort(),30_000)
  107. ctx.res.once('close',()=>abort.abort())
  108. let response: Response
  109. try {response=await target.client.fetchImpl(new URL(`${target.url.href.replace(/\/$/,'')}${path}${ctx.search}`),{
  110. method:ctx.method,redirect:'error',signal:abort.signal,
  111. headers:{Authorization:`Bearer ${target.pairedToken}`,'Content-Type':'application/json','Last-Event-ID':ctx.get('last-event-id'),'Idempotency-Key':ctx.get('idempotency-key'),'x-yaoyao-agent-hops':String(hops+1)},
  112. ...(body!==undefined ? {body:JSON.stringify(body)} : {}),
  113. })} finally {clearTimeout(timer)}
  114. ctx.status=response.status
  115. ctx.set('Content-Type',response.headers.get('content-type')||'application/json')
  116. if(response.headers.get('content-type')?.startsWith('text/event-stream') && response.body) {
  117. const interval=setInterval(()=>{if(!valid())abort.abort()},5000);interval.unref()
  118. ctx.res.once('close',()=>clearInterval(interval))
  119. const stream=Readable.fromWeb(response.body as any)
  120. stream.once('close',()=>clearInterval(interval));stream.once('error',()=>clearInterval(interval))
  121. ctx.body=stream
  122. }
  123. else if(response.status!==204) {ctx.body=await response.json();rememberChannel(ctx.body);remember(ctx.body)}
  124. return
  125. }
  126. if (ctx.method!=='GET') throw new HttpError(405,'机器人引用只允许读取文件与会话','method_not_allowed')
  127. pairings.authorize(device,token,'history.read')
  128. const history=/^\/api\/sessions\/([^/]+)\/messages$/.exec(path)
  129. if (history) requireBinding(decodeURIComponent(history[1]!))
  130. else if(path!=='/api/files/download') throw new HttpError(403,'路径不属于机器人引用','agent_path_forbidden')
  131. const query=new URLSearchParams(ctx.querystring);query.set('profile',agent.profile)
  132. const response=await target.session.request(path,{search:query,maxResponseBytes:25*1024*1024})
  133. ctx.status=response.status
  134. for(const name of ['content-type','content-disposition']) {const value=response.headers.get(name);if(value)ctx.set(name,value)}
  135. ctx.body=response.body
  136. }
  137. }