gateway.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. import {randomUUID} from 'node:crypto'
  2. import {join,dirname,posix} from 'node:path'
  3. import {fileURLToPath} from 'node:url'
  4. import {mkdir,rm} from 'node:fs/promises'
  5. import {existsSync} from 'node:fs'
  6. import type {DatabaseSync} from 'node:sqlite'
  7. import {z} from 'zod'
  8. import {HttpError} from '../../server/errors.js'
  9. import type {GatewayFrame} from '../../server/workspaceGateway.js'
  10. import type {RunnerConfiguration} from '../../shared/runner.js'
  11. import {ContainerComputerProvider,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
  12. import {ComputerPool,type ComputerLease} from '../computers/pool.js'
  13. import {ComputerPublicProxy} from '../network/computerProxy.js'
  14. import {HermesWorkerProcess,type WorkerModel,type WorkerTool,type WorkerFrame} from './process.js'
  15. export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string}
  16. export interface WorkerSession {id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain'}
  17. interface Live {proxy?:ComputerPublicProxy;session:WorkerSession;model:WorkerModel;lease:ComputerLease;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
  18. const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
  19. const text={type:'string'}
  20. const TOOLS:WorkerTool[]=[
  21. {name:'computer_export',description:'将隔离电脑中的文件作为可下载附件回传当前会话,最大 25 MiB。',inputSchema:object({path:text,name:text},['path'])},
  22. {name:'computer_shell',description:'在当前隔离电脑和 Profile 工作目录执行 shell 命令。',inputSchema:object({command:text},['command'])},
  23. {name:'computer_read_file',description:'读取隔离电脑内的 UTF-8 文本文件。',inputSchema:object({path:text},['path'])},
  24. {name:'computer_write_file',description:'向隔离电脑写入 UTF-8 文本文件。',inputSchema:object({path:text,content:text},['path','content'])},
  25. {name:'computer_desktop_state',description:'查看隔离电脑当前桌面,返回真实截图。',inputSchema:object({},[])},
  26. {name:'computer_action',description:'在隔离电脑内调用 CUA 动作;先查看桌面并使用可用动作。',inputSchema:object({name:text,arguments:{type:'object'}},['name','arguments'])},
  27. ]
  28. const pathField=z.string().min(1).max(4096).refine(value=>!value.includes('\0'))
  29. export class ComputerRuntime {
  30. readonly provider:ContainerComputerProvider
  31. readonly pool:ComputerPool
  32. readonly ready:Promise<void>
  33. readonly config:NonNullable<RunnerConfiguration['computers']>
  34. readonly workers=new Map<string,{environmentId:string;ownerKey:string;process:HermesWorkerProcess}>()
  35. readonly script:string
  36. readonly proxyScript:string
  37. constructor(readonly db:DatabaseSync,config:RunnerConfiguration,readonly home:string,script?:string){
  38. if(!config.computers)throw new HttpError(409,'执行节点尚未配置隔离电脑','computer_unavailable')
  39. this.config=config.computers
  40. this.script=script??join(dirname(fileURLToPath(import.meta.url)),'hermes_worker.py')
  41. this.proxyScript=existsSync(join(dirname(this.script),'guest_proxy.py'))?join(dirname(this.script),'guest_proxy.py'):join(dirname(this.script),'../network/guest_proxy.py')
  42. this.provider=new ContainerComputerProvider(this.config.runtime,config.runnerId,home)
  43. this.pool=new ComputerPool(db,this.provider,{concurrent:this.config.maxConcurrent??2,environments:32,ttlMs:30000})
  44. db.exec('CREATE TABLE IF NOT EXISTS retired_computers(id TEXT PRIMARY KEY,owner_key TEXT NOT NULL,spec TEXT)')
  45. db.exec('CREATE TABLE IF NOT EXISTS computer_sessions(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
  46. this.ready=this.pool.recover();void this.ready.catch(()=>{})
  47. }
  48. assertTarget(meta:ComputerTarget){if(this.db.prepare('SELECT id FROM retired_computers WHERE id=?').get(meta.environmentId))throw new HttpError(410,'临时电脑已退役','computer_retired')}
  49. async retire(meta:ComputerTarget){
  50. const old=this.db.prepare('SELECT owner_key,spec FROM retired_computers WHERE id=?').get(meta.environmentId) as {owner_key:string;spec?:string}|undefined
  51. if(old&&old.owner_key!==meta.ownerKey)throw new HttpError(403,'电脑归属不匹配','computer_owner_mismatch')
  52. this.pool.definition(meta.ownerKey,meta.environmentId)
  53. this.db.prepare('INSERT OR IGNORE INTO retired_computers VALUES(?,?,NULL)').run(meta.environmentId,meta.ownerKey)
  54. await Promise.all([...this.workers.values()].filter(worker=>worker.environmentId===meta.environmentId&&worker.ownerKey===meta.ownerKey).map(worker=>worker.process.close()))
  55. const current=this.pool.status(meta.ownerKey).find(row=>row.environmentId===meta.environmentId)
  56. if(current?.holderId)await this.pool.stopHolder(meta.ownerKey,meta.environmentId,current.holderId)
  57. let spec=old?.spec?JSON.parse(old.spec):undefined
  58. spec??=this.pool.definition(meta.ownerKey,meta.environmentId)
  59. if(spec)this.db.prepare('UPDATE retired_computers SET spec=? WHERE id=?').run(JSON.stringify(spec),meta.environmentId)
  60. await this.pool.retire(meta.ownerKey,meta.environmentId)
  61. if(spec)await this.provider.deleteWorkspace(spec)
  62. const sessions=(this.db.prepare('SELECT id,value FROM computer_sessions').all() as {id:string;value:string}[]).filter(row=>{const session=JSON.parse(row.value);return session.environmentId===meta.environmentId&&session.ownerKey===meta.ownerKey})
  63. for(const row of sessions){if(!/^[0-9a-f-]{36}$/.test(row.id))throw new Error('电脑会话编号无效');await rm(join(this.home,'workers',row.id),{recursive:true,force:true});this.db.prepare('DELETE FROM computer_sessions WHERE id=?').run(row.id)}
  64. this.db.prepare('UPDATE retired_computers SET spec=NULL WHERE id=?').run(meta.environmentId)
  65. }
  66. save(session:WorkerSession){this.db.prepare('INSERT INTO computer_sessions VALUES(?,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value').run(session.id,JSON.stringify(session))}
  67. session(id:string,meta:ComputerTarget,profile:string):WorkerSession {
  68. const row=this.db.prepare('SELECT value FROM computer_sessions WHERE id=?').get(id) as {value:string}|undefined
  69. const session:WorkerSession|undefined=row?JSON.parse(row.value):undefined
  70. if(!session||session.ownerKey!==meta.ownerKey||session.environmentId!==meta.environmentId||session.profile!==profile)throw new HttpError(404,'电脑会话不存在或不属于当前任务','computer_session_missing')
  71. return session
  72. }
  73. async resolve(profile:string,signal?:AbortSignal){
  74. const resolver=new HermesWorkerProcess(this.config.python,this.script,{mode:'resolve',profile,hermesSource:this.config.hermesSource,hermesHome:this.config.hermesHome})
  75. const abort=()=>{void resolver.close()};signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort()
  76. try{return await resolver.wait('resolved')}finally{signal?.removeEventListener('abort',abort);await resolver.close()}
  77. }
  78. }
  79. export class ComputerGateway {
  80. onEvent:(frame:GatewayFrame)=>void=()=>{}
  81. onDisconnect:()=>void=()=>{}
  82. private live?:Live
  83. private recoverySession?:WorkerSession
  84. private closed=false
  85. private controller=new AbortController()
  86. private timer?:ReturnType<typeof setInterval>
  87. private checking=false
  88. private team?:{id:string;catalog:WorkerTool[];call(name:string,args:Record<string,unknown>,callId:string):Promise<unknown>}
  89. constructor(readonly runtime:ComputerRuntime,readonly meta:ComputerTarget,readonly workId:string,readonly check:()=>Promise<void>,readonly publish:(body:Record<string,unknown>)=>Promise<any>){ }
  90. async connect(){this.runtime.assertTarget(this.meta);await this.runtime.ready;await this.authorized()}
  91. private async authorized(){try{await this.check();this.guard()}catch(error){await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect();throw error}}
  92. private guard(){if(this.closed||this.live?.controller.signal.aborted)throw new HttpError(410,'电脑任务授权已结束','computer_cancelled')}
  93. private event(type:string,payload:Record<string,unknown>){if(!this.closed&&this.live)this.onEvent({type,session_id:this.live.session.id,payload})}
  94. private spec(session:WorkerSession):ComputerSpecification{return {id:this.meta.environmentId,ownerKey:this.meta.ownerKey,imageId:this.runtime.config.imageId,cwd:session.cwd,network:this.runtime.config.network??'none'}}
  95. async rpc(method:string,params:Record<string,any>):Promise<any>{
  96. if((method==='session.interrupt'||method==='session.close')&&params.session_id===this.live?.session.id){await this.interrupt();return {status:'interrupted'}}
  97. if((method==='session.interrupt'||method==='session.close')&&params.session_id===this.recoverySession?.id){await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {status:'interrupted'}}
  98. this.guard()
  99. if(method==='session.create'||method==='session.resume'){
  100. if(this.live)throw new HttpError(409,'电脑通道已有会话','computer_session_busy')
  101. await this.authorized()
  102. if(method==='session.resume'&&params.recoverOnly===true){const session=this.runtime.session(String(params.session_id),this.meta,String(params.profile));this.recoverySession=session;const resource=this.runtime.pool.status(this.meta.ownerKey).find(item=>item.environmentId===this.meta.environmentId);if(resource?.holderId===this.workId)await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:session.profile}}}
  103. const profile=String(params.profile),resolved=await this.runtime.resolve(profile,this.controller.signal)
  104. await this.authorized();this.guard()
  105. const session:WorkerSession=method==='session.resume'?this.runtime.session(String(params.session_id),this.meta,profile):{id:randomUUID(),ownerKey:this.meta.ownerKey,environmentId:this.meta.environmentId,profile,cwd:resolved.cwd,configuredCwd:resolved.configuredCwd,history:[]}
  106. session.cwd=resolved.cwd;session.configuredCwd=resolved.configuredCwd
  107. await this.runtime.pool.configure(this.spec(session),()=>this.guard())
  108. await this.authorized()
  109. const controller=new AbortController(),lease=await this.runtime.pool.acquire(this.spec(session),this.workId,()=>{this.guard();if(controller.signal.aborted)throw new Error('cancelled')},controller.signal)
  110. if(this.closed){controller.abort();await this.runtime.pool.release(lease);throw new Error('电脑通道已关闭')}
  111. this.live={session,lease,controller,model:resolved.model as WorkerModel,images:[],running:false,received:false}
  112. this.runtime.save(session)
  113. if(this.runtime.config.network==='public-proxy'){
  114. const live=this.live
  115. try{live.proxy=await ComputerPublicProxy.start(this.runtime.provider,this.spec(session),this.runtime.proxyScript,()=>this.runtime.pool.authorize(lease),()=>this.authorized(),()=>{void this.interrupt().finally(()=>{if(!this.closed)this.onDisconnect()}).catch(()=>{})})}
  116. catch(error){await this.interrupt();throw error}
  117. }
  118. this.timer=setInterval(()=>{if(this.checking)return;this.checking=true;void this.authorized().then(()=>this.runtime.pool.renew(lease)).catch(async()=>{await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()}).finally(()=>{this.checking=false})},5000);this.timer.unref()
  119. return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:profile}}
  120. }
  121. const live=this.live
  122. if(!live||params.session_id!==live.session.id)throw new HttpError(403,'电脑会话不属于当前通道','computer_session_forbidden')
  123. if(method==='session.interrupt'||method==='session.close'){await this.interrupt();return {status:'interrupted'}}
  124. if(method==='session.usage')return {}
  125. if(method==='session.cwd.set'){
  126. if(params.cwd!==live.session.cwd&&params.cwd!==live.session.configuredCwd)throw new HttpError(403,'工作目录必须采用基础 Profile 的配置','computer_cwd_forbidden')
  127. return {cwd:live.session.cwd}
  128. }
  129. if(method==='file.attach'||method==='image.attach_bytes'){
  130. if(live.running)throw new HttpError(409,'当前轮次已开始,不能追加附件','computer_session_busy')
  131. await this.authorized()
  132. const name=String(params.name??params.filename??'attachment').replace(/[\/\\\u0000-\u001f]/g,'_').slice(0,200)||'attachment'
  133. const encoded=method==='image.attach_bytes'?String(params.content_base64??''):String(params.data_url??'').split(',')[1]??''
  134. const bytes=Buffer.from(encoded,'base64');if(bytes.length>25*1024*1024)throw new HttpError(413,'附件超过 25 MiB','attachment_too_large')
  135. const relative=`.yaoyao-inputs/${randomUUID()}/${name}`
  136. await this.runtime.pool.use(live.lease,context=>this.runtime.provider.execute(this.spec(live.session),['python3','-c','import sys,pathlib; p=pathlib.Path(sys.argv[1]); p.parent.mkdir(parents=True,exist_ok=True); p.write_bytes(sys.stdin.buffer.read())',relative],{...context,input:bytes}))
  137. if(method==='image.attach_bytes'){
  138. const mime=String(params.mime_type??'image/png')
  139. if(!/^image\/(?:png|jpeg|webp|gif)$/.test(mime))throw new HttpError(400,'图片格式无效','invalid_image')
  140. if(JSON.stringify(live.images).length+encoded.length>64*1024*1024)throw new HttpError(413,'本轮图片总量超过限制','image_limit')
  141. live.images.push({type:'image_url',image_url:{url:`data:${mime};base64,${encoded}`}})
  142. }
  143. return {ref_text:`[附件 ${name}](${live.session.cwd}/${relative})`}
  144. }
  145. if(method==='prompt.submit'){
  146. if(live.running||live.received)throw new HttpError(409,'电脑会话已经提交过本轮','computer_session_busy')
  147. await this.authorized();this.guard()
  148. const prompt=String(params.text??''),history=structuredClone(live.session.history)
  149. live.running=true;live.session.outcome='uncertain';live.session.history.push({role:'user',content:prompt});this.runtime.save(live.session)
  150. const directory=join(this.runtime.home,'workers',live.session.id,this.workId)
  151. await mkdir(directory,{recursive:true,mode:0o700})
  152. const worker=new HermesWorkerProcess(this.runtime.config.python,this.runtime.script,{mode:'run',home:directory,hermesSource:this.runtime.config.hermesSource,model:live.model,sessionId:live.session.id,taskId:this.workId,cwd:live.session.cwd,network:this.runtime.config.network??'none',tools:[...TOOLS,...(this.team?.catalog??[])],prompt:live.images.length?[{type:'text',text:prompt},...live.images]:prompt,history})
  153. live.worker=worker
  154. this.runtime.workers.set(this.workId,{environmentId:this.meta.environmentId,ownerKey:this.meta.ownerKey,process:worker});void worker.exited.then(()=>this.runtime.workers.delete(this.workId))
  155. worker.onTool=(name,args,id)=>this.tool(live,name,args,id)
  156. worker.onEvent=frame=>{
  157. if(frame.type==='delta')this.event('message.delta',{text:frame.text})
  158. if(frame.type==='reasoning')this.event('reasoning.delta',{text:frame.text})
  159. if(frame.type==='complete'){live.received=true;live.finishing=this.finish(live,frame);void live.finishing.catch(()=>this.lost(live))}
  160. if(frame.type==='failed')void this.lost(live)
  161. }
  162. void worker.exited.then(()=>{if(!live.received&&live.running&&!this.closed)void this.lost(live)})
  163. return {status:'streaming'}
  164. }
  165. throw new HttpError(409,'当前隔离 Worker 不支持此交互','computer_method_unavailable')
  166. }
  167. installTeamLease(id:string,catalog:WorkerTool[],call:(name:string,args:Record<string,unknown>,callId:string)=>Promise<unknown>){this.team={id,catalog,call}}
  168. removeTeamLease(id:string){if(this.team?.id===id)this.team=undefined}
  169. private async tool(live:Live,name:string,args:unknown,id:string):Promise<unknown>{
  170. await this.authorized();this.guard()
  171. this.event('tool.started',{tool_id:id,name})
  172. try{
  173. const team=this.team?.catalog.find(tool=>tool.name===name)
  174. if(team)return await this.team!.call(team.id??name,z.record(z.string(),z.unknown()).parse(args),id)
  175. return await this.runtime.pool.use(live.lease,async context=>{
  176. const execute=(argv:string[],input?:Buffer)=>this.runtime.provider.execute(this.spec(live.session),argv,{...context,input})
  177. if(name==='computer_export'){
  178. const body=z.object({path:pathField,name:z.string().min(1).max(240).optional()}).strict().parse(args)
  179. const snapshot=`/tmp/yaoyao-export-${randomUUID()}`
  180. try{
  181. const metadata=JSON.parse((await execute(['python3','-c','import pathlib,sys,shutil,json,hashlib; p=pathlib.Path(sys.argv[1]); assert p.is_file(),"not a file"; assert p.stat().st_size<=25*1024*1024,"file too large"; shutil.copyfile(p,sys.argv[2]); b=pathlib.Path(sys.argv[2]).read_bytes(); assert len(b)<=25*1024*1024,"file too large"; print(json.dumps({"size":len(b),"digest":hashlib.sha256(b).hexdigest()}))',body.path,snapshot])).stdout)
  182. await this.publish({phase:'begin',id,name:body.name??posix.basename(body.path),size:metadata.size,digest:metadata.digest})
  183. for(let offset=0,index=0;offset<metadata.size;offset+=524288,index++){
  184. context.authorize();await this.authorized()
  185. const data=(await execute(['python3','-c','import sys,base64; f=open(sys.argv[1],"rb"); f.seek(int(sys.argv[2])); print(base64.b64encode(f.read(524288)).decode())',snapshot,String(offset)])).stdout.trim()
  186. await this.publish({phase:'append',id,index,data})
  187. }
  188. context.authorize();await this.authorized()
  189. return (await this.publish({phase:'finish',id})).result
  190. }finally{await execute(['rm','-f','--',snapshot]).catch(()=>{})}
  191. }
  192. if(name==='computer_shell'){
  193. const body=z.object({command:z.string().min(1).max(65536)}).strict().parse(args)
  194. try{return {exitCode:0,...await execute(['/bin/bash','-lc',body.command])}}catch(error){if(typeof(error as any).code==='number')return {exitCode:(error as any).code,stdout:String((error as any).stdout??''),stderr:String((error as any).stderr??'')};throw error}
  195. }
  196. if(name==='computer_read_file'){
  197. const body=z.object({path:pathField}).strict().parse(args)
  198. return execute(['python3','-c','import sys; f=open(sys.argv[1],encoding="utf-8"); s=f.read(1000001); assert len(s)<=1000000,"file too large"; print(s,end="")',body.path])
  199. }
  200. if(name==='computer_write_file'){
  201. const body=z.object({path:pathField,content:z.string().max(1000000)}).strict().parse(args)
  202. await execute(['python3','-c','import pathlib,sys; p=pathlib.Path(sys.argv[1]); p.parent.mkdir(parents=True,exist_ok=True); p.write_bytes(sys.stdin.buffer.read())',body.path],Buffer.from(body.content));return {ok:true}
  203. }
  204. if(name==='computer_action'){
  205. const body=z.object({name:z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]{0,99}$/),arguments:z.record(z.string(),z.unknown())}).strict().parse(args)
  206. return execute([CUA_DRIVER,'call',body.name,JSON.stringify(body.arguments),'--socket',CUA_SOCKET])
  207. }
  208. if(name==='computer_desktop_state'){
  209. z.object({}).strict().parse(args)
  210. const path=`/tmp/yaoyao-frame-${randomUUID()}.png`
  211. const state=await execute([CUA_DRIVER,'call','get_desktop_state','{}','--socket',CUA_SOCKET,'--screenshot-out-file',path])
  212. const data=(await execute(['base64','-w0',path])).stdout.trim()
  213. return {_multimodal:true,text_summary:state.stdout,content:[{type:'image_url',image_url:{url:`data:image/png;base64,${data}`}}]}
  214. }
  215. throw new HttpError(403,'本轮未授权该工具','computer_tool_forbidden')
  216. })
  217. }finally{this.event('tool.completed',{tool_id:id,name})}
  218. }
  219. private async finish(live:Live,frame:WorkerFrame){
  220. if(this.closed||live.controller.signal.aborted)return
  221. await this.authorized()
  222. this.runtime.pool.renew(live.lease)
  223. const success=frame.completed!==false&&!frame.interrupted
  224. if(success&&Array.isArray(frame.messages)){live.session.history=frame.messages;live.session.outcome='complete'}else live.session.outcome='failed'
  225. this.runtime.save(live.session)
  226. await this.release(live)
  227. live.running=false
  228. if(this.closed||live.controller.signal.aborted)return
  229. this.event('message.complete',{text:String(frame.text??''),status:success?'complete':'failed',...(!success?{error:'隔离 Worker 未完成本轮'}:{})})
  230. }
  231. private release(live:Live){clearInterval(this.timer);live.releasing??=(async()=>{await live.proxy?.close();await this.runtime.pool.release(live.lease)})();return live.releasing}
  232. private async lost(live:Live){
  233. if(!live.running||this.closed||live.controller.signal.aborted)return
  234. live.running=false;await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()
  235. }
  236. async interrupt(){
  237. const live=this.live;if(!live)return
  238. if(live.stopping)return live.stopping
  239. live.stopping=(async()=>{
  240. live.controller.abort();clearInterval(this.timer)
  241. await live.worker?.close()
  242. try{await this.release(live)}catch(error){if((error as any).code!=='computer_lease_stale')throw error}
  243. live.running=false
  244. })()
  245. return live.stopping
  246. }
  247. close(){this.closed=true;this.controller.abort();this.team=undefined;clearInterval(this.timer);return this.interrupt()}
  248. }