localVm.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. import Router from '@koa/router'
  2. import { z } from 'zod'
  3. import { existsSync, mkdirSync } from 'node:fs'
  4. import { homedir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { execFile } from 'node:child_process'
  7. import { promisify } from 'node:util'
  8. import { randomUUID } from 'node:crypto'
  9. import { RunnerAgent } from '../runner/agent.js'
  10. import { UNCONFIGURED_COMPUTER_IMAGE, type RunnerConfiguration } from '../shared/runner.js'
  11. import type { LocalVmMode, LocalVmStatus } from '../shared/localVm.js'
  12. import {LOCAL_VM_IMAGE_KEYS,MAX_VM_IDLE_STOP_MINUTES} from '../shared/localVm.js'
  13. import type { WorkspaceAgent } from '../shared/workspace.js'
  14. import { parse, type WorkspaceStore } from './workspaceStore.js'
  15. import type { LocalAuthStore } from './localAuth.js'
  16. import type { WorkspaceNodes } from './workspaceGateway.js'
  17. import type { RunnerHub } from './runnerHub.js'
  18. import type { SharedComputers } from './sharedComputers.js'
  19. import type { ServerConfig } from './config.js'
  20. import { readHostTools } from './hostToolSettings.js'
  21. import { HttpError } from './errors.js'
  22. interface Grant {id:string;owner:string;runnerId:string;version:number;expiresAt:number}
  23. const exec=promisify(execFile)
  24. /** Settings owns preparation. Chats own each Agent's desktop and its lifecycle. */
  25. export class LocalVmService {
  26. private url?:string
  27. private managed?:{agent:RunnerAgent;abort:AbortController;done:Promise<void>}
  28. private starting?:Promise<void>
  29. private problem?:string
  30. constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,
  31. readonly hub:RunnerHub,readonly shared:SharedComputers,readonly config:ServerConfig) {
  32. store.prepareAgent=(owner,agent)=>{
  33. const record=this.record()
  34. // VM placement never changes where a Bot's Hermes session runs.
  35. if(this.fixed){const parent=agent.createdByAgentId?store.get<WorkspaceAgent>(owner,'agent',agent.createdByAgentId):undefined,desktop=this.config.composeDesktops?.find(d=>d.id===parent?.computerEnvironmentId);if(record&&desktop){if(agent.nodeId!=='local')throw new HttpError(409,'Compose 桌面成员须使用当前 Web 的 Hermes 来源','compose_source_required');this.shared.bindCompose(owner,agent,desktop,record.id,false)}return}
  36. if(record&&this.mode(owner)==='shared')this.shared.attachLocalVm(owner,agent,record.id)
  37. }
  38. }
  39. private get fixed(){return !!this.config.composeDesktops?.length}
  40. private assertManaged(){if(this.fixed)throw new HttpError(409,'桌面由 Compose 创建和管理,数量和镜像不能在界面修改','compose_desktop_managed')}
  41. allowed(id:string,runnerId:string){
  42. const grant=this.store.get<Grant>('_system','local-vm-grant',id)
  43. return !!grant&&grant.runnerId===runnerId&&grant.expiresAt>Date.now()&&this.auth.isAdminActive(grant.owner)&&this.auth.pushAuthorizationVersion(grant.owner)===grant.version
  44. }
  45. private mode(owner:string):LocalVmMode { return this.fixed?'shared':this.store.get<{mode:LocalVmMode}>(owner,'local-vm-preferences','local')?.mode??'per-bot' }
  46. /** Heals groups stranded on a superseded runner record; without it those
  47. * members keep failing with shared_runner_changed and never rejoin sharing. */
  48. private reconcile(owner:string,record:{id:string}|undefined){
  49. if(this.fixed||!record||this.mode(owner)!=='shared')return
  50. try{this.shared.reconcileLocalVmGroups(owner,record.id)}catch{/* best effort; retried on the next check */}
  51. }
  52. private async stopDesktops(owner:string,runnerId:string,environmentIds:string[]){
  53. for(const environmentId of environmentIds){
  54. const id=randomUUID();this.grant(owner,id,runnerId)
  55. await this.hub.localVm(owner,runnerId,{op:'instance',id,environmentId,action:'stop'}).catch(()=>{})
  56. }
  57. }
  58. private record(){return this.hub.records().find(r=>r.enabled&&r.sourceNodeId==='local'&&r.sourceOwner==='_system')}
  59. async start(url:string) {
  60. this.url=url
  61. if(this.fixed||this.config.localVmHost==='runner')return
  62. const stored=this.store.get<{sealed:string}>('_system','local-vm-managed','local')
  63. if(!stored||this.managed)return
  64. const config=this.nodes.open<RunnerConfiguration>(stored.sealed)
  65. if(!this.hub.records().some(r=>r.id===config.runnerId&&r.enabled))return
  66. config.serverURL=url
  67. const home=join(this.config.home,'runner-state','local-vm',config.runnerId)
  68. mkdirSync(home,{recursive:true,mode:0o700})
  69. const abort=new AbortController(),agent=new RunnerAgent(config,home)
  70. const state={agent,abort,done:Promise.resolve()}
  71. this.managed=state
  72. state.done=agent.run(abort.signal).catch(error=>{this.problem=error instanceof Error?error.message:'本地虚拟机服务已断开'}).finally(()=>{if(this.managed===state)this.managed=undefined})
  73. }
  74. private async discover(){
  75. for(const runtime of ['docker','podman'] as const) {
  76. try {
  77. await exec(runtime,['--version'],{timeout:5000})
  78. try { await exec(runtime,['info'],{timeout:8000,maxBuffer:1024*1024});return {runtime,daemonUp:true} }
  79. catch{return {runtime,daemonUp:false}}
  80. }catch{}
  81. }
  82. return {runtime:undefined,daemonUp:false}
  83. }
  84. private installation(){
  85. const hermesHome=join(homedir(),'.hermes'),hermesSource=join(hermesHome,'hermes-agent')
  86. return {hermesHome,hermesSource,python:join(hermesSource,'venv','bin','python')}
  87. }
  88. private async ensure(owner:string) {
  89. if(this.starting)await this.starting
  90. if(this.record()){
  91. if(this.url&&!this.managed)await this.start(this.url)
  92. return this.record()!
  93. }
  94. if(this.config.localVmHost==='runner')throw new HttpError(409,'Docker 版需要先连接运行 Hermes 的执行节点,并启用虚拟桌面。请在本页打开执行节点设置。','local_vm_runner_required')
  95. this.starting=(async()=>{
  96. if(!this.url)throw new HttpError(503,'本机 Web 服务尚未启动','local_vm_unavailable')
  97. const detected=await this.discover()
  98. if(!detected.runtime||!detected.daemonUp)throw new HttpError(409,'请先安装并启动 Docker 或 Podman','local_vm_runtime_required')
  99. const {hermesHome,hermesSource,python}=this.installation()
  100. if(!existsSync(python)||!existsSync(join(hermesSource,'run_agent.py')))throw new HttpError(409,'没有找到本机 Hermes 运行环境。请先安装 Hermes,或在高级连接设置中配置执行节点','local_vm_hermes_required')
  101. const sources=await this.nodes.sources(owner)
  102. const allowedProfiles=[...new Set(sources.sources.filter(s=>s.nodeId==='local').map(s=>s.profile))]
  103. const registered=this.hub.enroll(owner,{name:'本机虚拟机',sourceNodeId:'local',allowedProfiles})
  104. const config:RunnerConfiguration={protocol:1,serverURL:this.url,runnerId:registered.runner.id,token:registered.token,
  105. hermesURL:this.config.upstream.origin,allowedProfiles,artifactRoots:[],computers:{runtime:detected.runtime,
  106. imageId:UNCONFIGURED_COMPUTER_IMAGE,python,hermesSource,hermesHome,maxConcurrent:2,network:'public-proxy'}}
  107. this.store.put('_system','local-vm-managed','local',{sealed:this.nodes.seal(config)})
  108. await this.start(this.url)
  109. })().finally(()=>{this.starting=undefined})
  110. await this.starting
  111. const record=this.record()!
  112. this.reconcile(owner,record)
  113. const deadline=Date.now()+8000
  114. while(!this.hub.summary(record).online&&Date.now()<deadline)await new Promise(r=>setTimeout(r,100))
  115. return record
  116. }
  117. private grant(owner:string,id:string,runnerId:string){
  118. const old=this.store.get<Grant>('_system','local-vm-grant',id)
  119. if(old&&(old.owner!==owner||old.runnerId!==runnerId))throw new HttpError(409,'请求编号已用于其他操作','idempotency_conflict')
  120. this.store.put('_system','local-vm-grant',id,{id,owner,runnerId,version:this.auth.pushAuthorizationVersion(owner)??0,expiresAt:Date.now()+3600000})
  121. }
  122. async status(owner:string):Promise<LocalVmStatus> {
  123. const record=this.record()
  124. if(this.fixed){
  125. const desktops=(await this.hub.composeDesktops.status()).map(d=>{const claim=this.store.get<{owner:string}>('_system','compose-desktop-owner',d.id);return {...d,available:!claim||claim.owner===owner}})
  126. const runner=record?this.hub.summary(record):undefined,connected=runner?.online&&runner.features.includes('compose-desktops-v1')
  127. return {configured:!!connected,executionHost:'runner',runnerName:record?.name,fixedCapacity:true,desktops,daemonUp:true,image:desktops.some(d=>d.ready),mode:'shared',maxInstances:desktops.length,busy:false,...(!connected?{setupRequired:'runner' as const,problem:'Compose 桌面数量已固定。请连接启用 Compose 桌面模式的 Hermes 执行节点。'}:{})}
  128. }
  129. this.reconcile(owner,record)
  130. const executionHost=this.config.localVmHost==='runner'||(record&&!this.store.get('_system','local-vm-managed','local'))?'runner':'server'
  131. if(record){
  132. if(this.url&&!this.managed)await this.start(this.url)
  133. const summary=this.hub.summary(record)
  134. if(!summary.online){
  135. if(executionHost==='runner')return {configured:true,executionHost,runnerName:record.name,setupRequired:'runner',daemonUp:false,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:`执行节点「${record.name}」尚未连接,请在运行 Hermes 的电脑上启动 Runner。`}
  136. const detected=await this.discover()
  137. return {configured:true,...detected,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:this.problem??'本机执行环境正在连接,请稍后重新检查'}
  138. }
  139. if(!summary.features?.includes('local-vm-v1'))return {configured:true,executionHost,runnerName:record.name,setupRequired:'worker',daemonUp:false,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:'执行节点已连接,但未启用虚拟桌面或版本过旧。请启用隔离电脑 Worker 并使用配套版本的 Runner。'}
  140. const state=await this.hub.localVm(owner,record.id,{op:'status'}) as LocalVmStatus
  141. const agents=this.store.list<WorkspaceAgent>(owner,'agent'),groups=this.store.list<{id:string;name:string}>(owner,'shared-computer')
  142. return {...state,executionHost,runnerName:record.name,mode:this.mode(owner),instances:state.instances?.map(instance=>{
  143. const agent=agents.find(a=>!a.archived&&(a.computerEnvironmentId??a.id)===instance.id)
  144. return {...instance,name:agent?.computerEnvironmentName??agent?.name??groups.find(g=>g.id===instance.id)?.name??'保留的虚拟机',orphaned:!agent}
  145. })}
  146. }
  147. if(executionHost==='runner')return {configured:false,executionHost,setupRequired:'runner',daemonUp:false,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:'Docker 版通过执行节点提供虚拟桌面。请在运行 Hermes 的电脑上连接执行节点。'}
  148. const detected=await this.discover()
  149. return {configured:false,...detected,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:this.problem}
  150. }
  151. router(){
  152. const router=new Router()
  153. router.get('/api/app/admin/local-vm',async ctx=>{ctx.body=await this.status(this.auth.requireAdmin(ctx).id)})
  154. router.post('/api/app/admin/local-vm/prepare',async ctx=>{
  155. this.auth.requireAdmin(ctx);this.assertManaged()
  156. const actor=this.auth.requireAdmin(ctx),body=parse(z.object({requestId:z.string().uuid(),imageKey:z.enum(LOCAL_VM_IMAGE_KEYS).optional()}).strict(),(ctx.request as any).body)
  157. const record=await this.ensure(actor.id);this.grant(actor.id,body.requestId,record.id)
  158. ctx.body=await this.hub.localVm(actor.id,record.id,{op:'prepare',id:body.requestId,...(body.imageKey?{imageKey:body.imageKey}:{})})
  159. })
  160. router.put('/api/app/admin/local-vm/idle-policy',async ctx=>{
  161. const owner=this.auth.requireAdmin(ctx).id;this.assertManaged()
  162. const body=parse(z.object({requestId:z.string().uuid(),idleStopMinutes:z.number().int().min(0).max(MAX_VM_IDLE_STOP_MINUTES)}).strict(),(ctx.request as any).body)
  163. const record=await this.ensure(owner);this.grant(owner,body.requestId,record.id)
  164. ctx.body=await this.hub.localVm(owner,record.id,{op:'idle-policy',id:body.requestId,idleStopMinutes:body.idleStopMinutes})
  165. })
  166. router.put('/api/app/admin/local-vm/policy',async ctx=>{
  167. this.auth.requireAdmin(ctx);this.assertManaged()
  168. const owner=this.auth.requireAdmin(ctx).id,body=parse(z.object({requestId:z.string().uuid(),mode:z.enum(['shared','per-bot']),maxInstances:z.number().int().min(1).max(4)}).strict(),(ctx.request as any).body)
  169. const record=await this.ensure(owner);this.shared.assertLocalVmIdle(owner)
  170. this.grant(owner,body.requestId,record.id)
  171. await this.hub.localVm(owner,record.id,{op:'policy',id:body.requestId,mode:body.mode,maxInstances:body.maxInstances})
  172. const {retiredEnvironmentIds}=this.shared.setLocalVmMode(owner,body.mode,record.id)
  173. this.store.put(owner,'local-vm-preferences','local',{mode:body.mode})
  174. await this.stopDesktops(owner,record.id,retiredEnvironmentIds)
  175. ctx.body=await this.status(owner)
  176. })
  177. router.post('/api/app/admin/local-vm/instances/:id/:action',async ctx=>{
  178. this.auth.requireAdmin(ctx);this.assertManaged()
  179. const owner=this.auth.requireAdmin(ctx).id,id=parse(z.string().uuid(),ctx.params.id),action=parse(z.enum(['stop','remove']),ctx.params.action)
  180. const body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body),record=this.record()
  181. if(!record)throw new HttpError(409,'本地虚拟机尚未配置','local_vm_unavailable')
  182. this.grant(owner,body.requestId,record.id)
  183. ctx.body=await this.hub.localVm(owner,record.id,{op:'instance',id:body.requestId,environmentId:id,action})
  184. })
  185. router.get('/api/app/agents/:id/local-vm',async ctx=>{
  186. const owner=this.auth.require(ctx).id,agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id)
  187. this.nodes.requireSource(owner,agent)
  188. const fixed=this.fixed?{fixedCapacity:true,desktopId:agent.computerEnvironmentId,desktops:(await this.status(owner)).desktops}:{}
  189. if(!readHostTools(this.store.home).vm||(this.fixed&&!agent.computerEnvironmentId)){ctx.body={enabled:false,agent,...fixed};return}
  190. try { ctx.body={enabled:true,agent,...await this.hub.computer(owner,agent,'detail',{},()=>this.nodes.requireSource(owner,agent)),mode:agent.computerEnvironmentId?'shared':'per-bot',...fixed} }
  191. catch(error){ctx.body={enabled:true,agent,container:'missing',ready:false,image:false,problem:error instanceof Error?error.message:'本地虚拟机不可用',...fixed}}
  192. })
  193. router.put('/api/app/agents/:id/local-vm',async ctx=>{
  194. const owner=this.auth.require(ctx).id,body=parse(z.object({enabled:z.boolean(),desktopId:z.string().uuid().optional()}).strict(),(ctx.request as any).body)
  195. const agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);this.nodes.requireSource(owner,agent)
  196. if(agent.remoteAgentId||agent.temporaryGoalId)throw new HttpError(409,'请在该机器人所属的电脑上设置虚拟机','local_vm_remote')
  197. this.shared.assertLocalVmIdle(owner,[agent.id])
  198. if(this.fixed&&body.enabled){
  199. if(agent.nodeId!=='local')throw new HttpError(409,'Compose 桌面成员须使用当前 Web 的 Hermes 来源','compose_source_required')
  200. const desktop=this.config.composeDesktops!.find(d=>d.id===body.desktopId),record=this.record()
  201. if(!desktop)throw new HttpError(400,'请选择 Compose 中已有的共享桌面','compose_desktop_required')
  202. if(!record||!this.hub.summary(record).features.includes('compose-desktops-v1'))throw new HttpError(409,'请连接启用 Compose 桌面模式的执行节点','compose_runner_required')
  203. ctx.body=this.store.atomic(()=>{this.shared.bindCompose(owner,agent,desktop,record.id);return {agent:this.store.agentSummary(agent)}});return
  204. }
  205. let retiredAfterEnable:string[]=[]
  206. ctx.body=this.store.atomic(()=>{
  207. if(!body.enabled)this.shared.detachLocalVm(owner,agent)
  208. this.store.updateAgent(owner,agent.id,{execution:body.enabled?'computer':'profile',computer:body.enabled?'vm':'off'})
  209. if(body.enabled&&!this.fixed&&this.mode(owner)==='shared'){const record=this.record();if(record)retiredAfterEnable=this.shared.setLocalVmMode(owner,'shared',record.id).retiredEnvironmentIds}
  210. return {agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
  211. })
  212. if(retiredAfterEnable.length){const record=this.record();if(record)await this.stopDesktops(owner,record.id,retiredAfterEnable)}
  213. })
  214. router.put('/api/app/agents/:id/local-vm/image',async ctx=>{
  215. const owner=this.auth.require(ctx).id;this.assertManaged()
  216. const body=parse(z.object({imageKey:z.enum(LOCAL_VM_IMAGE_KEYS)}).strict(),(ctx.request as any).body)
  217. const agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id)
  218. this.nodes.requireSource(owner,agent)
  219. if(!readHostTools(this.store.home).vm||agent.archived||agent.remoteAgentId)throw new HttpError(409,'此机器人不能更改虚拟机镜像','local_vm_disabled')
  220. this.shared.assertLocalVmIdle(owner,agent.computerEnvironmentId?this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>a.computerEnvironmentId===agent.computerEnvironmentId).map(a=>a.id):[agent.id])
  221. ctx.body=await this.hub.computer(owner,agent,'image',{imageKey:body.imageKey},()=>this.nodes.requireSource(owner,agent))
  222. })
  223. router.post('/api/app/agents/:id/local-vm/:action',async ctx=>{
  224. this.auth.require(ctx);this.assertManaged()
  225. const owner=this.auth.require(ctx).id,action=parse(z.enum(['create','start','stop','recreate','remove']),ctx.params.action)
  226. const agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id)
  227. if(agent.temporaryGoalId)throw new HttpError(409,'临时助手的虚拟机由当前任务管理','helper_task_bound')
  228. if(!readHostTools(this.store.home).vm||agent.archived||agent.remoteAgentId)throw new HttpError(409,'此机器人未使用本地虚拟机','local_vm_disabled')
  229. this.shared.assertLocalVmIdle(owner,agent.computerEnvironmentId?this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>a.computerEnvironmentId===agent.computerEnvironmentId).map(a=>a.id):[agent.id])
  230. ctx.body=await this.hub.computer(owner,agent,'lifecycle',{action},()=>this.nodes.requireSource(owner,agent))
  231. })
  232. return router
  233. }
  234. async close(){this.managed?.abort.abort();await this.managed?.done}
  235. }