agent.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. import {checkProxy} from './network/upstreamProxy.js'
  2. import { randomUUID, createHash } from 'node:crypto'
  3. import { DatabaseSync } from 'node:sqlite'
  4. import { realpath } from 'node:fs/promises'
  5. import { resolve, sep, join } from 'node:path'
  6. import {LocalVmImages} from './computers/localVm.js'
  7. import {LOCAL_VM_IMAGE_KEYS,MAX_VM_IDLE_STOP_MINUTES} from '../shared/localVm.js'
  8. import {ComputerError} from './computers/container.js'
  9. import {z} from 'zod'
  10. import {ComputerRuntime,ComputerGateway,type ComputerTarget} from './worker/gateway.js'
  11. import { HttpError } from '../server/errors.js'
  12. import { UpstreamClient } from '../server/upstream.js'
  13. import { UpstreamServiceSession } from '../server/localAuth.js'
  14. import { WorkspaceGateway, type GatewayTarget, type GatewayFrame } from '../server/workspaceGateway.js'
  15. import { createWorkspaceToolLease, type WorkspaceToolLease } from '../server/workspaceToolLease.js'
  16. import {UNCONFIGURED_COMPUTER_IMAGE} from '../shared/runner.js'
  17. import {assertRunnerHermes} from './config.js'
  18. import {RunnerBrowser} from './managedBrowser.js'
  19. import {BrowserRuntimeError} from './browser/types.js'
  20. import type { RunnerCommand, RunnerConfiguration } from '../shared/runner.js'
  21. import { LoopbackTransport, isLocalAuthorizationTarget } from '../server/loopbackAuthorization.js'
  22. const commands=new Set(['session.create','session.resume','session.close','session.usage','session.interrupt','session.active_list','session.cwd.set','prompt.submit','session.steer','image.attach_bytes','file.attach','approval.respond','clarify.respond'])
  23. interface Connection {cleanupOnly?:boolean;gateway:WorkspaceGateway|ComputerGateway;sessions:Map<string,string>;running:Set<string>;events:Promise<void>}
  24. interface Lease {connectionId:string;profile:string;lease:WorkspaceToolLease;controller:AbortController;session:{runtimeId:string;storedId:string}}
  25. export class RunnerAgent {
  26. readonly instance=randomUUID()
  27. private active=true
  28. private connected=false
  29. private disconnectRevision=0
  30. private disconnecting?:Promise<void>
  31. private connections=new Map<string,Connection>()
  32. private leases=new Map<string,Lease>()
  33. private closedConnections=new Map<string,number>()
  34. private closedLeases=new Map<string,number>()
  35. private inflight=new Map<string,Promise<unknown>>()
  36. private db:DatabaseSync
  37. private computers?:ComputerRuntime
  38. private localVm?:LocalVmImages
  39. private browsers?:RunnerBrowser
  40. private target:GatewayTarget
  41. private controlTransport=new LoopbackTransport()
  42. private serverEpoch?:string
  43. private controlAbort=new AbortController()
  44. onConnected:()=>void=()=>{}
  45. constructor(readonly config:RunnerConfiguration,home:string,readonly fetchImpl:typeof fetch=fetch,readonly onStatus:(message:string)=>void=()=>{}) {
  46. const web=new URL(config.serverURL),hermes=new URL(config.hermesURL)
  47. if(web.username||web.password||web.search||web.hash||!['http:','https:'].includes(web.protocol))throw new Error('Web 地址无效')
  48. if(web.protocol==='http:'&&!['127.0.0.1','localhost','[::1]'].includes(web.hostname)&&!config.allowInsecureLan)throw new Error('远程执行节点必须使用 HTTPS,或明确配置可信局域网 HTTP')
  49. assertRunnerHermes(hermes, config.satellite, config.allowInsecureLan)
  50. const client=new UpstreamClient(hermes)
  51. this.target={url:hermes,client,session:new UpstreamServiceSession(client,()=>config.hermesCredentials)}
  52. this.db=new DatabaseSync(join(home,'runner-commands.sqlite3'))
  53. this.db.exec('CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,state TEXT NOT NULL,result TEXT,created INTEGER NOT NULL)')
  54. if(config.computers){this.computers=new ComputerRuntime(this.db,config,home,undefined,(id,operation,body)=>this.api('desktop',{id,operation,...body}),this.target);this.localVm=new LocalVmImages(this.computers,this.db)}
  55. // Embedded runners can load pre-browser sealed configs without the CLI parser.
  56. // Only an explicit opt-out disables setup; it never changes VM isolation.
  57. const browser={...config.browser,enabled:config.browser?.enabled!==false}
  58. if(browser.enabled)this.browsers=new RunnerBrowser(browser,home,async(scope,grantId)=>{
  59. if(!this.active||!this.connected||(await this.api('browser-check',{scope,grantId})).allowed!==true)throw new HttpError(403,'浏览器授权已失效','browser_authorization_revoked')
  60. })
  61. this.compactReceipts()
  62. }
  63. private async api(path:string,body?:unknown):Promise<any> {
  64. const url=new URL(`/api/runner/v1/${this.config.runnerId}/${path}`,this.config.serverURL)
  65. if(url.hostname==='localhost')url.hostname='127.0.0.1'
  66. const toolId=body&&typeof body==='object'&&'toolId' in body?body.toolId:undefined
  67. const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
  68. method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(path==='desktop'?75000:path==='tool'&&typeof toolId==='string'&&(toolId.startsWith('managed_browser_')||toolId.startsWith('credential_'))?600000:25000)]),
  69. headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':(this.computers?'execution-context-v1,vm-egress-v1,workspace-memory-bind-v1,hermes-computer-v2,profile-computer-v1,host-computer-tools-v1,file-transfer-v1,idle-stop-policy-v1,computer-worker-v1,artifact-chunks-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'execution-context-v1,workspace-memory-bind-v1')+(this.browsers?',managed-browser-setup-v1,managed-browser-retention-v1':',managed-browser-disabled-v1')+(this.browsers?.available?',managed-browser-v1':'')+',credential-ref-v1,bot-context-v1',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
  70. ...(body===undefined?{}:{body:JSON.stringify(body)}),
  71. })
  72. if(!response.ok){
  73. const error=await response.json().catch(()=>({})) as {error?:string;code?:string}
  74. if(path==='desktop')throw new HttpError(response.status,error.error?.slice(0,300)||'Compose 桌面连接失败',error.code||'compose_desktop_error')
  75. // Surface the server's real tool/desktop error (e.g. path out of range) instead of a generic "connection refused".
  76. throw new HttpError(response.status,error.error?.slice(0,300)||`执行节点请求失败(HTTP ${response.status})`,error.code||(response.status===403?'runner_unauthorized':'runner_request_failed'))
  77. }
  78. const reader=response.body?.getReader();const chunks:Uint8Array[]=[];let bytes=0
  79. if(!reader)throw new Error('执行节点响应为空')
  80. try{for(;;){const part=await reader.read();if(part.done)break;bytes+=part.value.length;if(bytes>40*1024*1024)throw new Error('执行节点响应超过大小限制');chunks.push(part.value)}}finally{await reader.cancel()}
  81. return JSON.parse(Buffer.concat(chunks).toString())
  82. }
  83. private requireProfile(profile:unknown):string {
  84. if(typeof profile!=='string'||!this.config.allowedProfiles.includes(profile))throw new HttpError(403,'Profile 未授权','runner_profile_forbidden')
  85. return profile
  86. }
  87. private async closeConnection(id:string,interrupt=true) {
  88. this.closedConnections.set(id,Date.now()+60000)
  89. const connection=this.connections.get(id)
  90. if(!connection)return
  91. this.connections.delete(id)
  92. for(const [leaseId,lease] of this.leases)if(lease.connectionId===id){this.closedLeases.set(leaseId,Date.now()+60000);lease.controller.abort();this.leases.delete(leaseId);void lease.lease.dispose()}
  93. if(interrupt){let timer:ReturnType<typeof setTimeout>|undefined;try{await Promise.race([Promise.all([...connection.running].map(session_id=>connection.gateway.rpc('session.interrupt',{session_id}).catch(()=>{}))),new Promise(resolve=>{timer=setTimeout(resolve,3000)})])}finally{clearTimeout(timer)}}
  94. await connection.gateway.close()
  95. }
  96. private disconnect():Promise<void> {
  97. if(this.disconnecting)return this.disconnecting
  98. this.disconnectRevision++;this.connected=false
  99. const leases=[...this.leases.values()];this.leases.clear()
  100. for(const value of leases)value.controller.abort()
  101. const pending=Promise.all([
  102. ...leases.map(value=>value.lease.dispose()),
  103. ...(this.browsers?[this.browsers.disconnect()]:[]),
  104. ...[...this.connections.keys()].map(id=>this.closeConnection(id)),
  105. ]).then(()=>{}).finally(()=>{if(this.disconnecting===pending)this.disconnecting=undefined})
  106. this.disconnecting=pending;return pending
  107. }
  108. private async syncNetworkSettings(){
  109. const {proxy}=await this.api('network-settings',{})
  110. await this.computers!.updateProxy(proxy)
  111. await this.api('network-settings',{appliedRevision:this.computers!.proxySettings?.revision??0})
  112. }
  113. private async execute(command:RunnerCommand):Promise<any> {
  114. const p=command.payload
  115. if(!this.active||!this.connected)throw new Error('执行节点已断开')
  116. if(command.kind==='browser.invoke'){
  117. if(!this.browsers)throw new HttpError(409,'托管浏览器未配置','browser_unavailable')
  118. return this.browsers.invoke(p,command.expiresAt,profile=>{this.requireProfile(profile)})
  119. }
  120. if(command.kind==='local-vm.manage'){
  121. if(!this.localVm)throw new HttpError(409,'本地虚拟机尚未配置','computer_unavailable')
  122. const input=z.object({owner:z.string().regex(/^[a-f0-9]{64}$/),op:z.enum(['status','prepare','policy','idle-policy','instance']),idleStopMinutes:z.number().int().min(0).max(MAX_VM_IDLE_STOP_MINUTES).optional(),id:z.string().uuid().optional(),imageKey:z.enum(LOCAL_VM_IMAGE_KEYS).optional(),mode:z.enum(['shared','per-bot']).optional(),maxInstances:z.number().int().min(1).max(4).optional(),environmentId:z.string().uuid().optional(),action:z.enum(['stop','remove']).optional()}).strict().parse(p)
  123. const check=async()=>{if(!this.connected||!this.active||(await this.api('local-vm-check',{id:input.id})).allowed!==true)throw new ComputerError('local_vm_authorization_revoked','本地虚拟机配置授权已失效')}
  124. if(input.op==='status')return this.localVm.status(input.owner)
  125. if(input.op==='instance'){
  126. await check()
  127. const spec=this.computers!.pool.definition(input.owner,input.environmentId!)
  128. if(spec)await this.computers!.pool.desktop(spec,input.action!,()=>{if(!this.active||!this.connected)throw new Error('节点已断开')})
  129. await check();return {ok:true}
  130. }
  131. if(input.op==='prepare')return this.localVm.prepare(input.id!,check,input.imageKey)
  132. await check();if(input.op==='idle-policy')return this.localVm.idlePolicy(input.idleStopMinutes!)
  133. return this.localVm.policy(input.mode!,input.maxInstances!)
  134. }
  135. if(command.kind==='computer.control'){
  136. if(!this.computers)throw new HttpError(409,'电脑服务未配置','computer_unavailable')
  137. const input=z.object({target:z.object({environmentId:z.string().uuid(),agentId:z.string().uuid(),ownerKey:z.string().regex(/^[a-f0-9]{64}$/)}).strict(),profile:z.string(),op:z.enum(['status','detail','image','lifecycle','autostart','frame','take','input','giveback']),imageKey:z.enum(LOCAL_VM_IMAGE_KEYS).optional(),controlId:z.string().uuid().optional(),requestId:z.string().uuid().optional(),generation:z.number().int().optional(),frameId:z.string().uuid().optional(),action:z.unknown().optional(),notes:z.string().max(4000).optional()}).strict().parse(p)
  138. this.requireProfile(input.profile)
  139. const check=async()=>{if(!this.connected||!input.controlId||(await this.api('control-check',{controlId:input.controlId})).allowed!==true)throw new HttpError(403,'电脑控制授权已失效','computer_control_expired')}
  140. const authorize=()=>{if(!this.connected||!this.active||Date.now()>command.expiresAt)throw new HttpError(403,'电脑请求已失效','computer_control_expired')}
  141. await this.computers.ready
  142. if(input.op==='autostart'){
  143. await this.localVm!.ready
  144. if(!this.computers.pool.definition(input.target.ownerKey,input.target.environmentId))return {ok:true,skipped:true}
  145. await this.syncNetworkSettings()
  146. await this.computers.restoreDesktop(input.target,authorize)
  147. return {ok:true}
  148. }
  149. if(input.op==='detail'){
  150. const vm=await this.localVm!.status(),spec=this.computers.pool.definition(input.target.ownerKey,input.target.environmentId)??(this.computers.provider.fixedCapacity?{id:input.target.environmentId,ownerKey:input.target.ownerKey,imageId:this.computers.config.imageId}:undefined)
  151. const state=await this.computers.controls.status(input.target)
  152. const actual=spec?await this.computers.provider.inspect(spec):undefined
  153. let ready=false
  154. if(actual?.running)try{await this.computers.provider.health(spec!,authorize);ready=true}catch{}
  155. const imageId=this.computers.imageFor(input.target),option=vm.images?.find(image=>image.imageId===imageId)
  156. let image=!!option?.ready
  157. if(!image&&!vm.fixedCapacity&&imageId!==UNCONFIGURED_COMPUTER_IMAGE)try{await this.localVm!.images.inspect(imageId);image=true}catch{}
  158. return {...state,container:actual?.running?'running':spec?'stopped':'missing',ready,inUse:['agent','pausing','human','resuming'].includes(state.mode),mode:vm.mode,maxInstances:vm.maxInstances,idleStopMinutes:vm.idleStopMinutes,image:vm.fixedCapacity?vm.image:image,imageId,images:vm.images,imageKey:option?.key,controlMode:state.mode,fixedCapacity:vm.fixedCapacity}
  159. }
  160. if(input.op==='image')return this.localVm!.select(input.target,z.enum(LOCAL_VM_IMAGE_KEYS).parse(input.imageKey),authorize)
  161. if(input.op==='lifecycle'){
  162. const action=z.enum(['create','start','stop','recreate','remove']).parse(input.action)
  163. if(!['stop','remove'].includes(action))await this.syncNetworkSettings()
  164. await this.computers.desktop(input.target,input.profile,action,authorize)
  165. return {ok:true}
  166. }
  167. if(input.op==='status')return this.computers.controls.status(input.target)
  168. if(input.op==='frame')return this.computers.controls.frame(input.target,authorize)
  169. if(input.op==='take'){await this.syncNetworkSettings();return this.computers.controls.take(input.target,input.profile,input.controlId!,check)}
  170. if(input.op==='input')return this.computers.controls.input(input.target,input.controlId!,input.requestId!,input.generation!,input.frameId!,input.action)
  171. return this.computers.controls.giveBack(input.target,input.controlId!,input.notes??'')
  172. }
  173. if(command.kind==='computer.retire'){
  174. const meta=p as unknown as ComputerTarget
  175. if(!this.computers||![meta.environmentId,meta.agentId].every(value=>/^[0-9a-f-]{36}$/.test(value))||!/^[a-f0-9]{64}$/.test(meta.ownerKey))throw new HttpError(400,'电脑清理请求无效','computer_cleanup_invalid')
  176. await this.computers.retire(meta);return {ok:true}
  177. }
  178. if(command.kind==='http') {
  179. const path=String(p.path),search=new URLSearchParams(String(p.search??''))
  180. if (path === '/api/plugins/yaoyao-bot-bridge/memory-extract') {
  181. if (p.method !== 'POST' || !this.computers) throw new HttpError(409, '执行节点尚不支持无工具记忆提炼', 'memory_extraction_unavailable')
  182. const body = z.object({ profile: z.string().min(1).max(256), prompt: z.string().min(1).max(65000) }).strict().parse(p.body)
  183. if (body.profile !== (search.get('profile') ?? 'default')) throw new HttpError(403, '提炼 Profile 与授权来源不一致', 'memory_profile_forbidden')
  184. this.requireProfile(body.profile)
  185. const text = await this.computers.extractMemory(body.profile, body.prompt, this.controlAbort.signal)
  186. return { status: 200, headers: {}, body: Buffer.from(JSON.stringify({ text })).toString('base64') }
  187. }
  188. if(path==='/api/computer/capabilities'){
  189. this.requireProfile(search.get('profile')??'default')
  190. if(p.method!=='GET'||!this.computers)throw new HttpError(409,'执行节点未配置隔离电脑','computer_unavailable')
  191. const response=await this.target.session.request('/api/plugins/yaoyao-bot-bridge/capabilities',{search,cache:'reload'})
  192. const value=JSON.parse(response.body.toString())
  193. return {status:response.status,headers:{},body:Buffer.from(JSON.stringify({...value,ready:value.ready===true&&value.computer_runtime_version===2,network:this.config.computers?.network??'none'})).toString('base64')}
  194. }
  195. if(p.computer&&path==='/api/files/download')throw new HttpError(409,'隔离电脑产物需通过产物导出接口读取','computer_artifact_required')
  196. if(p.computer&&/^\/api\/sessions\/[^/]+\/messages$/.test(path)){
  197. if(p.method!=='GET'||!this.computers)throw new HttpError(403,'电脑历史不可用','computer_unavailable')
  198. const profile=this.requireProfile(search.get('profile')??'default'),session=this.computers.session(decodeURIComponent(path.split('/')[3]!),p.computer as ComputerTarget,profile)
  199. if(session.profileSessionId){
  200. if(!session.profileSessionId)throw new HttpError(409,'本机 Profile 会话尚未建立','computer_profile_session_invalid')
  201. const response=await this.target.session.request(`/api/sessions/${encodeURIComponent(session.profileSessionId)}/messages`,{search,maxResponseBytes:8*1024*1024})
  202. return {status:response.status,headers:Object.fromEntries(response.headers.entries()),body:response.body.toString('base64')}
  203. }
  204. const offset=Math.max(0,Number(search.get('offset')??0)),history=session.history
  205. return {status:200,headers:{},body:Buffer.from(JSON.stringify({messages:history.slice(Math.max(0,history.length-offset-500),history.length-offset)})).toString('base64')}
  206. }
  207. if(p.method!=='GET'||!(/^\/api\/(?:status|profiles|config)$/.test(path)||/^\/api\/sessions\/[^/]+\/messages$/.test(path)||path==='/api/plugins/yaoyao-bot-bridge/capabilities'||path==='/api/files/download'))throw new HttpError(403,'Runner 不支持此 HTTP 操作','runner_http_forbidden')
  208. if(!['/api/status','/api/profiles'].includes(path))this.requireProfile(search.get('profile')??'default')
  209. if(path==='/api/files/download') {
  210. const requested=await realpath(String(search.get('path')??''))
  211. const roots=await Promise.all(this.config.artifactRoots.map(root=>realpath(root)))
  212. if(!roots.some(root=>requested===root||requested.startsWith(root+sep)))throw new HttpError(403,'文件不在允许的产物目录','runner_artifact_forbidden')
  213. }
  214. const response=await this.target.session.request(path,{search,maxResponseBytes:8*1024*1024})
  215. return {status:response.status,headers:Object.fromEntries(response.headers.entries()),body:response.body.toString('base64')}
  216. }
  217. if(command.kind==='network.configure'||command.kind==='network.test'){
  218. if(!this.computers)throw new Error('节点没有虚拟环境')
  219. const {proxy}=await this.api('network-settings',{})
  220. if(command.kind==='network.test'){
  221. // A reachable upstream alone does not make the VM's network usable.
  222. await this.computers.provider.verifyNetwork()
  223. return checkProxy(proxy)
  224. }
  225. await this.computers.updateProxy(proxy);return {revision:proxy?.revision??0}
  226. }
  227. const connectionId=String(p.connectionId??'')
  228. if(command.kind==='gateway.open') {
  229. if(this.closedConnections.has(connectionId)||this.connections.has(connectionId)||!/^[0-9a-f-]{36}$/.test(connectionId)||this.connections.size>=64)throw new Error('执行通道无效或已达上限')
  230. let gateway:WorkspaceGateway|ComputerGateway
  231. if(p.computer){
  232. const meta=p.computer as ComputerTarget
  233. if(!this.computers||![meta.environmentId,meta.agentId,String(p.workId)].every(value=>/^[0-9a-f-]{36}$/.test(value))||!/^[a-f0-9]{64}$/.test(meta.ownerKey))throw new HttpError(409,'电脑任务配置或授权无效','computer_unavailable')
  234. await this.syncNetworkSettings()
  235. const trustedMeta={...meta,hermesRuntime:true}
  236. gateway=new ComputerGateway(this.computers,trustedMeta,String(p.workId),async()=>{const state=await this.api('check',{connectionId});if(!this.active||!this.connected||state.allowed!==true)throw new HttpError(403,'电脑任务授权已失效','computer_authorization_revoked');trustedMeta.fileTransferMaxBytes=Number.isInteger(state.fileTransferMaxBytes)&&state.fileTransferMaxBytes>=1024*1024&&state.fileTransferMaxBytes<=100*1024*1024?state.fileTransferMaxBytes:25*1024*1024},body=>this.api('artifact',{connectionId,...body}),this.target)
  237. }else gateway=new WorkspaceGateway(this.target)
  238. const connection:Connection={cleanupOnly:p.cleanupOnly===true,gateway,sessions:new Map(),running:new Set(),events:Promise.resolve()}
  239. if(gateway instanceof ComputerGateway)gateway.taskEnvironment=async()=> (await this.api('execution-context',{connectionId})).environment
  240. this.connections.set(connectionId,connection)
  241. gateway.onEvent=(frame:GatewayFrame)=>{
  242. if(frame.session_id&&!connection.sessions.has(frame.session_id))return
  243. if(frame.session_id&&['message.complete','run.completed','run.failed'].includes(frame.type))connection.running.delete(frame.session_id)
  244. if(frame.type==='session.info'&&frame.session_id)for(const lease of this.leases.values())if(lease.session.runtimeId===frame.session_id&&typeof frame.payload?.stored_session_id==='string')lease.session.storedId=frame.payload.stored_session_id
  245. connection.events=connection.events.then(()=>this.api('event',{connectionId,frame})).then(()=>{},error=>{if(error instanceof HttpError&&error.status===410)void this.closeConnection(connectionId);else void this.disconnect()})
  246. }
  247. gateway.onDisconnect=()=>{void this.api('event',{connectionId,closed:true}).catch(()=>{})}
  248. try {await gateway.connect();if(!this.active||!this.connected||this.connections.get(connectionId)!==connection)throw new Error('执行节点已断开');return {ok:true}}catch(error){this.connections.delete(connectionId);gateway.close();throw error}
  249. }
  250. if(command.kind==='gateway.close'){await this.closeConnection(connectionId);return {ok:true}}
  251. if(command.kind==='gateway.rpc') {
  252. const connection=this.connections.get(connectionId),method=String(p.method),params=p.params as Record<string,unknown>
  253. const computerCommand=method==='computer.invoke'||method==='computer.transfer'||method==='computer.complete'
  254. if(!connection||(!commands.has(method)&&!(computerCommand&&connection.gateway instanceof ComputerGateway))||!params||typeof params!=='object')throw new HttpError(403,'执行通道或命令无效','runner_command_forbidden')
  255. if(connection.cleanupOnly&&!['session.resume','session.interrupt','session.close','session.active_list'].includes(method))throw new HttpError(403,'清理通道不允许执行新工作','runner_cleanup_forbidden')
  256. if(method==='session.active_list'){
  257. const id=String(params.current_session_id??''),profile=connection.sessions.get(id)
  258. if(!profile||params.profile!==profile||Object.keys(params).some(key=>!['profile','current_session_id'].includes(key)))throw new HttpError(403,'停止状态查询不属于这个通道','runner_session_forbidden')
  259. if(connection.gateway instanceof ComputerGateway)throw new HttpError(409,'电脑通道使用独立停止确认','computer_method_unavailable')
  260. const state=await connection.gateway.rpc(method,{profile,current_session_id:id})
  261. if(!Array.isArray(state?.sessions))throw new HttpError(502,'Hermes 停止状态无效','session_stop_unconfirmed')
  262. return {sessions:state.sessions.filter((item:{id:string})=>item.id===id).map((item:{id:string;status:string})=>({id:item.id,status:item.status}))}
  263. }
  264. if(method==='session.create'||method==='session.resume'){if(!connection.cleanupOnly)this.requireProfile(params.profile)}
  265. else if(!connection.sessions.has(String(params.session_id)))throw new HttpError(403,'会话不属于这个通道','runner_session_forbidden')
  266. if(method==='prompt.submit')connection.running.add(String(params.session_id))
  267. const result=await connection.gateway.rpc(method,params)
  268. if(method==='session.create'||method==='session.resume'){
  269. if(this.connections.get(connectionId)!==connection)throw new HttpError(409,'执行通道已关闭','runner_command_uncertain')
  270. connection.sessions.set(String(result.session_id),String(params.profile))
  271. }
  272. if(method==='session.interrupt')connection.running.delete(String(params.session_id))
  273. return result
  274. }
  275. const leaseId=String(p.leaseId??'')
  276. if(command.kind==='lease.create') {
  277. this.requireProfile(p.profile)
  278. if(this.closedLeases.has(leaseId)||this.leases.has(leaseId)||!/^[0-9a-f-]{36}$/.test(leaseId)||this.leases.size>=64)throw new Error('工具授权无效或已达上限')
  279. const session=p.session as {runtimeId:string;storedId:string}
  280. const source=[...this.connections].find(([,c])=>c.sessions.get(session?.runtimeId)===p.profile)
  281. if(!source)throw new HttpError(403,'工具会话不属于此节点','runner_session_forbidden')
  282. const controller=new AbortController()
  283. const virtual=source[1].gateway
  284. if(virtual instanceof ComputerGateway){
  285. virtual.installTeamLease(leaseId,p.catalog as any[],async(toolId,args,callId)=>(await this.api('tool',{leaseId,toolId,arguments:args,callId})).result,p.workspaceMemory===true)
  286. const lease={bind:async()=>{},dispose:async()=>{virtual.removeTeamLease(leaseId)}}
  287. this.leases.set(leaseId,{lease,controller,session,connectionId:source[0],profile:String(p.profile)});return {ok:true}
  288. }
  289. const lease=await createWorkspaceToolLease({target:this.target,profile:String(p.profile),workId:String(p.workId),session:()=>session,signal:controller.signal,
  290. workspaceMemory:p.workspaceMemory===true,computerPolicy:p.computerPolicy as import('../server/workspaceToolLease.js').LeaseInput['computerPolicy'],
  291. assertActive:()=>{if(!this.connected||controller.signal.aborted)throw new Error('工具授权已失效')},
  292. catalog:()=>p.catalog as any[],call:async(toolId,args,callId)=>(await this.api('tool',{leaseId,toolId,arguments:args,callId:callId??randomUUID()})).result,
  293. onFailure:()=>{controller.abort()},
  294. })
  295. if(!this.active||!this.connected||this.closedLeases.has(leaseId)||!this.connections.has(source[0])){controller.abort();await lease.dispose();throw new Error('工具授权已停止')}
  296. this.leases.set(leaseId,{lease,controller,session,connectionId:source[0],profile:String(p.profile)});return {ok:true}
  297. }
  298. if(command.kind==='lease.bind') {
  299. const lease=this.leases.get(leaseId)
  300. if(!lease)throw new HttpError(410,'工具授权已关闭','runner_lease_expired')
  301. const update=p.session as {runtimeId?:string;storedId?:string}|undefined
  302. if(update?.runtimeId!==lease.session.runtimeId||!this.connections.get(lease.connectionId)?.sessions.has(update.runtimeId))throw new HttpError(403,'工具不能改绑其他会话','runner_session_forbidden')
  303. if(typeof update.storedId!=='string')throw new Error('存储会话编号无效')
  304. lease.session.storedId=update.storedId;await lease.lease.bind();return {ok:true}
  305. }
  306. if(command.kind==='lease.context') {
  307. const lease=this.leases.get(leaseId)
  308. if(!lease||!this.connections.has(lease.connectionId))throw new HttpError(410,'工具授权已关闭','runner_lease_expired')
  309. if(typeof p.prompt!=='string'||typeof p.historyText!=='string')throw new Error('上下文格式无效')
  310. return {enabled:await lease.lease.prepareContext?.(p.prompt,p.historyText)??false}
  311. }
  312. if(command.kind==='lease.close') {
  313. this.closedLeases.set(leaseId,Date.now()+60000)
  314. const lease=this.leases.get(leaseId);this.leases.delete(leaseId)
  315. if(lease){lease.controller.abort();await lease.lease.dispose()}return {ok:true}
  316. }
  317. throw new HttpError(400,'未知执行命令','runner_command_unknown')
  318. }
  319. private compactReceipts(){
  320. // Keep fingerprints as replay tombstones; large HTTP/file replies expire.
  321. this.db.prepare("UPDATE commands SET state='retired',result=NULL WHERE state='complete' AND created<?").run(Date.now()-3600000)
  322. }
  323. private handled=0
  324. async handle(command:RunnerCommand):Promise<unknown> {
  325. if(++this.handled%256===0)this.compactReceipts()
  326. for(const map of [this.closedConnections,this.closedLeases])for(const [id,expires] of map)if(expires<Date.now())map.delete(id)
  327. const existing=this.inflight.get(command.id)
  328. const fingerprint=createHash('sha256').update(JSON.stringify(command)).digest('hex')
  329. const previous=this.db.prepare('SELECT * FROM commands WHERE id=?').get(command.id)
  330. if(previous){
  331. if(previous.fingerprint!==fingerprint)throw new HttpError(409,'命令编号已用于其他内容','idempotency_conflict')
  332. if(existing)return existing
  333. if(previous.state==='complete')return JSON.parse(String(previous.result))
  334. throw new HttpError(409,'原命令结果不确定,禁止重复执行','runner_command_uncertain')
  335. }
  336. this.db.prepare("INSERT INTO commands VALUES(?,?,'started',NULL,?)").run(command.id,fingerprint,Date.now())
  337. const pending=Promise.resolve().then(async()=>{
  338. if(command.expiresAt<=Date.now()||!(await this.api('admit',{id:command.id})).allowed||command.expiresAt<=Date.now())throw new HttpError(403,'命令授权已经失效,未执行','runner_command_not_admitted')
  339. return this.execute(command)
  340. }).then(result=>({result}),error=>({error:{message:(error instanceof Error&&error.message?error.message:'执行节点操作失败').slice(0,1000),code:error instanceof HttpError||error instanceof ComputerError||error instanceof BrowserRuntimeError?error.code:'runner_error'}}))
  341. .then(reply=>{this.db.prepare("UPDATE commands SET state='complete',result=? WHERE id=?").run(JSON.stringify(reply),command.id);return reply})
  342. .finally(()=>this.inflight.delete(command.id))
  343. this.inflight.set(command.id,pending);return pending
  344. }
  345. private lastStatus=''
  346. private status(message:string){if(message!==this.lastStatus){this.lastStatus=message;this.onStatus(message)}}
  347. async run(signal:AbortSignal):Promise<void> {
  348. const deliveries=new Map<string,Promise<void>>()
  349. const cleanupTimer=setInterval(()=>{void this.computers?.pool.expire().catch(()=>{});void this.browsers?.sweep().catch(()=>{})},5000);cleanupTimer.unref()
  350. const abort=()=>{this.active=false;this.controlAbort.abort();void this.disconnect()};signal.addEventListener('abort',abort,{once:true})
  351. try {
  352. while(this.active&&!signal.aborted) {
  353. try {
  354. const revision=this.disconnectRevision
  355. const response=await this.api('poll')
  356. await this.disconnecting
  357. if(revision!==this.disconnectRevision)continue
  358. if(this.serverEpoch&&this.serverEpoch!==response.epoch)await this.disconnect()
  359. const newlyConnected=!this.connected
  360. this.serverEpoch=response.epoch;this.connected=true;this.status('执行节点已连接')
  361. if(newlyConnected)this.onConnected()
  362. for(const command of response.commands as RunnerCommand[]){
  363. if(deliveries.has(command.id))continue
  364. const delivery=(async()=>{
  365. let reply
  366. try {reply=await this.handle(command)}catch(error){reply={error:{message:'原命令需要核对',code:error instanceof HttpError?error.code:'runner_error'}}}
  367. await this.api('result',{id:command.id,...reply as object})
  368. })().catch(()=>{}).finally(()=>deliveries.delete(command.id))
  369. deliveries.set(command.id,delivery)
  370. }
  371. }catch(error){
  372. this.status(error instanceof HttpError?error.message:'连接暂时中断,正在重连')
  373. await this.disconnect()
  374. if(error instanceof HttpError&&error.code==='runner_unauthorized')throw error
  375. if(this.active)await new Promise(resolve=>setTimeout(resolve,1000))
  376. }
  377. }
  378. }finally{
  379. clearInterval(cleanupTimer)
  380. signal.removeEventListener('abort',abort)
  381. try{
  382. await this.disconnect().catch(()=>{})
  383. await this.browsers?.shutdown()
  384. await Promise.allSettled(deliveries.values());await Promise.allSettled(this.inflight.values())
  385. await this.localVm?.close();await this.computers?.controls.close();await this.computers?.pool.close()
  386. }finally{this.target.client.close();this.controlTransport.close();this.db.close();this.status('执行节点已停止')}
  387. }
  388. }
  389. }