Browse Source

feat: Yaoyao 服务启动时恢复已配置的本机虚拟机

Samien 1 tháng trước cách đây
mục cha
commit
cf433f1d45

+ 3 - 1
docs/local-vm.md

@@ -11,7 +11,9 @@
 5. 右侧显示真实画面及运行状态,可停止或重建虚拟机;点击预览或 **打开桌面** 后接管交互。
 6. **打开双桌面** 在主工作区显示两台虚拟机,保留聊天侧栏。进入时仅查看,不会创建/启动机器;切换操作对象时先交还前一台的控制权。
 
-虚拟机实例移除或重建均保留工作目录和浏览器资料。正常完成任务和主动交还后桌面进入空闲状态;默认空闲 5 分钟后自动停止。应用设置 → **本地虚拟机 → 空闲自动停止** 可选择 1、5、10、15、30、60、120 分钟或 **永不自动停止**。设置保存在执行节点,适用于该节点的所有动态本地虚拟机。再次执行任务或接管时结束空闲计时,交还后重新计时;仅查看画面不会重置计时。
+启用全局虚拟机功能后,Yaoyao 服务启动并连接托管的本机 Runner 时,会自动启动已经创建、仍有有效成员授权的虚拟机;无需等待 Bot 首次执行命令。共享桌面只启动一次,沿用原镜像和工作目录,并遵守运行数量上限。已移除的实例、临时助手及没有有效成员的旧桌面不会自动创建或启动。Docker/Podman 和镜像须可用;启动失败会在设置中提示,不阻塞 Web 服务。高级外部 Runner 和 Compose 桌面继续由其部署方式管理启动。
+
+虚拟机实例移除或重建均保留工作目录和浏览器资料。自动启动、正常完成任务和主动交还后桌面进入空闲状态;默认空闲 5 分钟后自动停止。应用设置 → **本地虚拟机 → 空闲自动停止** 可选择 1、5、10、15、30、60、120 分钟或 **永不自动停止**。设置保存在执行节点,适用于该节点的所有动态本地虚拟机。再次执行任务或接管时结束空闲计时,交还后重新计时;仅查看画面不会重置计时。服务运行期间手动停止或空闲超时后,不会因 Runner 重连而自动拉起。
 
 Bot 通过虚拟机工具执行命令或打开网页后,正常回复结束只释放本轮控制权,保留桌面、窗口及正在运行的程序,再按上述空闲策略回收;工具连接结束本身不代表关闭虚拟机。外部 Runner 也需更新才能支持这项正常收尾。
 

+ 1 - 1
src/client/components/workspace/LocalVmChatPanel.vue

@@ -164,7 +164,7 @@ onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer
     <div class="computer-options">
     <section v-if="computers.managedBrowser" class="managed-browser-option" aria-label="托管浏览器">
       <strong>托管浏览器</strong>
-      <p>网页操作使用独立浏览器,虚拟机按任务需要单独启动。查看状态不会打开网页。</p>
+      <p>网页操作使用独立浏览器;服务启动时会启动已配置的本机虚拟机,任务也可按需启动。查看状态不会打开网页。</p>
       <p v-if="!managedBrowser?.available" class="hint" role="status">{{browserError||managedBrowser?.installation?.message||managedBrowser?.reason||'执行节点尚未提供浏览器能力,请检查节点配置与连接。'}}</p>
       <progress v-if="managedBrowser?.installation?.status==='installing'" :value="managedBrowser.installation.progress" max="100" aria-label="浏览器准备进度"/>
       <button v-if="!managedBrowser?.available" type="button" :disabled="busy||recheckingBrowser" :aria-busy="recheckingBrowser" @click="recheckBrowser">{{recheckingBrowser?'正在检测…':'重新检测'}}</button>

+ 11 - 1
src/runner/agent.ts

@@ -43,6 +43,7 @@ export class RunnerAgent {
   private controlTransport=new LoopbackTransport()
   private serverEpoch?:string
   private controlAbort=new AbortController()
+  onConnected:()=>void=()=>{}
   constructor(readonly config:RunnerConfiguration,home:string,readonly fetchImpl:typeof fetch=fetch,readonly onStatus:(message:string)=>void=()=>{}) {
     const web=new URL(config.serverURL),hermes=new URL(config.hermesURL)
     if(web.username||web.password||web.search||web.hash||!['http:','https:'].includes(web.protocol))throw new Error('Web 地址无效')
@@ -135,11 +136,18 @@ export class RunnerAgent {
     }
     if(command.kind==='computer.control'){
       if(!this.computers)throw new HttpError(409,'电脑服务未配置','computer_unavailable')
-      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','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)
+      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)
       this.requireProfile(input.profile)
       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')}
       const authorize=()=>{if(!this.connected||!this.active||Date.now()>command.expiresAt)throw new HttpError(403,'电脑请求已失效','computer_control_expired')}
       await this.computers.ready
+      if(input.op==='autostart'){
+        await this.localVm!.ready
+        if(!this.computers.pool.definition(input.target.ownerKey,input.target.environmentId))return {ok:true,skipped:true}
+        await this.syncNetworkSettings()
+        await this.computers.restoreDesktop(input.target,authorize)
+        return {ok:true}
+      }
       if(input.op==='detail'){
         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)
         const state=await this.computers.controls.status(input.target)
@@ -336,7 +344,9 @@ export class RunnerAgent {
           await this.disconnecting
           if(revision!==this.disconnectRevision)continue
           if(this.serverEpoch&&this.serverEpoch!==response.epoch)await this.disconnect()
+          const newlyConnected=!this.connected
           this.serverEpoch=response.epoch;this.connected=true;this.status('执行节点已连接')
+          if(newlyConnected)this.onConnected()
           for(const command of response.commands as RunnerCommand[]){
             if(deliveries.has(command.id))continue
             const delivery=(async()=>{

+ 8 - 2
src/runner/computers/pool.ts

@@ -163,15 +163,21 @@ export class ComputerPool {
     })
   }
   definition(ownerKey:string,id:string){const entry=this.get(id);if(entry&&entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配');return entry?.spec}
-  async desktop(spec:ComputerSpecification,action:'create'|'start'|'stop'|'recreate'|'remove',authorize:()=>void){
+  async desktop(spec:ComputerSpecification,action:'create'|'start'|'stop'|'recreate'|'remove'|'restore',authorize:()=>void){
     spec=this.provider.validateSpecification(spec)
     await this.serial(spec.id,async()=>{
       authorize()
       if(!this.ready||this.closing||this.maintenance)throw new ComputerError('computer_busy','本地虚拟机正在维护')
       let entry=this.get(spec.id)
       if(entry?.spec.ownerKey!==undefined&&entry.spec.ownerKey!==spec.ownerKey)throw new ComputerError('computer_owner_mismatch','虚拟机归属不匹配')
+      // Startup only restores saved, stopped desktops. Serialize this check
+      // with manual removal and task acquisition so neither can be undone.
+      if(action==='restore'){
+        if(!entry||entry.status!=='free')return
+        spec={...entry.spec,network:spec.network}
+      }
       if(entry&&!['free','idle'].includes(entry.status))throw new ComputerError('computer_busy','请先停止机器人任务并交还控制权')
-      const starts=['create','start','recreate'].includes(action)
+      const starts=['create','start','recreate','restore'].includes(action)
       if(starts&&this.rows().filter(row=>row.status!=='free'&&row.spec.id!==spec.id).length>=this.limits.concurrent)throw new ComputerError('computer_quota','本地虚拟机数量已达上限,请先停止另一台桌面')
       if(!entry&&!starts)return
       entry??={spec,generation:0,status:'free',holders:new Map(),expiresAt:0,updatedAt:this.now()};this.entries.set(spec.id,entry)

+ 6 - 0
src/runner/worker/gateway.ts

@@ -209,6 +209,12 @@ export class ComputerRuntime {
     const abort=()=>{void resolver.close()};signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort()
     try{const result=await resolver.wait('resolved');return this.provider.fixedCapacity?{...result,cwd:COMPUTER_WORKSPACE}:result}finally{signal?.removeEventListener('abort',abort);await resolver.close()}
   }
+  async restoreDesktop(meta:ComputerTarget,authorize:()=>void){
+    if(this.provider.fixedCapacity)return
+    this.assertTarget(meta);authorize();this.assertNetworkReady()
+    const spec=this.pool.definition(meta.ownerKey,meta.environmentId)
+    if(spec)await this.pool.desktop({...spec,network:this.network},'restore',authorize)
+  }
   async desktop(meta:ComputerTarget,profile:string,action:'create'|'start'|'stop'|'recreate'|'remove',authorize:()=>void){
     if(this.provider.fixedCapacity)throw new HttpError(409,'桌面由 Compose 创建和管理,不能在界面增删或重建','compose_desktop_managed')
     this.assertTarget(meta);authorize()

+ 39 - 6
src/server/localVm.ts

@@ -23,10 +23,10 @@ import { HttpError } from './errors.js'
 interface Grant {id:string;owner:string;runnerId:string;version:number;expiresAt:number}
 const exec=promisify(execFile)
 
-/** Settings owns preparation. Chats own each Agent's desktop and its lifecycle. */
+/** Settings owns preparation; service startup restores configured desktops. */
 export class LocalVmService {
   private url?:string
-  private managed?:{agent:RunnerAgent;abort:AbortController;done:Promise<void>}
+  private managed?:{agent:RunnerAgent;abort:AbortController;done:Promise<void>;warmup:Promise<void>}
   private starting?:Promise<void>
   private problem?:string
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,
@@ -58,6 +58,29 @@ export class LocalVmService {
     }
   }
   private record(){return this.hub.records().find(r=>r.enabled&&r.sourceNodeId==='local'&&r.sourceOwner==='_system')}
+  private async restoreDesktops(runnerId:string,signal:AbortSignal){
+    const restored=new Set<string>()
+    for(const owner of this.store.owners()){
+      if(signal.aborted||!readHostTools(this.store.home).vm)return
+      if(!this.auth.isUserActive(owner))continue
+      this.reconcile(owner,this.record())
+      for(const agent of this.store.list<WorkspaceAgent>(owner,'agent')){
+        const environmentId=agent.computerEnvironmentId??agent.id,key=`${owner}:${environmentId}`
+        if(agent.nodeId!=='local'||agent.archived||agent.remoteAgentId||agent.temporaryGoalId||restored.has(key))continue
+        const authorize=()=>{
+          const current=this.store.require<WorkspaceAgent>(owner,'agent',agent.id)
+          if(signal.aborted||!readHostTools(this.store.home).vm||current.archived||current.remoteAgentId||current.temporaryGoalId||current.nodeId!=='local'||current.profile!==agent.profile||(current.computerEnvironmentId??current.id)!==environmentId)throw new HttpError(403,'虚拟机启动授权已失效','local_vm_disabled')
+          this.nodes.requireSource(owner,current)
+          if(this.hub.computerRunner(owner,current).id!==runnerId)throw new HttpError(409,'虚拟机执行节点已变化','local_vm_runner_changed')
+        }
+        try{authorize()}catch{continue}
+        restored.add(key)
+        try{await this.hub.computer(owner,agent,'autostart',{},authorize)}catch{
+          if(!signal.aborted)this.problem='部分已配置虚拟机未能自动启动,请检查 Docker、镜像和运行数量上限,或在电脑面板手动启动。'
+        }
+      }
+    }
+  }
   async start(url:string) {
     this.url=url
     if(this.fixed||this.config.localVmHost==='runner')return
@@ -68,8 +91,18 @@ export class LocalVmService {
     config.serverURL=url
     const home=join(this.config.home,'runner-state','local-vm',config.runnerId)
     mkdirSync(home,{recursive:true,mode:0o700})
-    const abort=new AbortController(),agent=new RunnerAgent(config,home)
-    const state={agent,abort,done:Promise.resolve()}
+    this.problem=undefined
+    const abort=new AbortController()
+    let warming=false
+    const agent=new RunnerAgent(config,home)
+    agent.onConnected=()=>{
+      // A transport reconnect must not undo a manual stop or idle expiry.
+      if(!warming&&!abort.signal.aborted){
+        warming=true
+        state.warmup=this.restoreDesktops(config.runnerId,abort.signal).catch(()=>{if(!abort.signal.aborted)this.problem='虚拟机自动启动未完成,请在电脑面板检查状态。'})
+      }
+    }
+    const state={agent,abort,done:Promise.resolve(),warmup:Promise.resolve()}
     this.managed=state
     state.done=agent.run(abort.signal).catch(error=>{this.problem=error instanceof Error?error.message:'本地虚拟机服务已断开'}).finally(()=>{if(this.managed===state)this.managed=undefined})
   }
@@ -141,7 +174,7 @@ export class LocalVmService {
       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。'}
       const state=await this.hub.localVm(owner,record.id,{op:'status'}) as LocalVmStatus
       const agents=this.store.list<WorkspaceAgent>(owner,'agent'),groups=this.store.list<{id:string;name:string}>(owner,'shared-computer')
-      return {...state,executionHost,runnerName:record.name,mode:this.mode(owner),instances:state.instances?.map(instance=>{
+      return {...state,problem:state.problem??this.problem,executionHost,runnerName:record.name,mode:this.mode(owner),instances:state.instances?.map(instance=>{
         const agent=agents.find(a=>!a.archived&&(a.computerEnvironmentId??a.id)===instance.id)
         return {...instance,name:agent?.computerEnvironmentName??agent?.name??groups.find(g=>g.id===instance.id)?.name??'保留的虚拟机',orphaned:!agent}
       })}
@@ -233,5 +266,5 @@ export class LocalVmService {
     })
     return router
   }
-  async close(){this.managed?.abort.abort();await this.managed?.done}
+  async close(){const managed=this.managed;managed?.abort.abort();await managed?.done;await managed?.warmup}
 }

+ 1 - 1
src/server/runnerHub.ts

@@ -37,7 +37,7 @@ export class RunnerHub {
   private networkApplied=new Map<string,{revision:number;error?:string}>()
   networkStatus(){return this.records().filter(r=>r.enabled).map(r=>{const s=this.summary(r),applied=this.networkApplied.get(r.id);return {id:r.id,name:r.name,online:s.online,status:!s.online?'离线':!s.features.includes('vm-egress-v1')?'需要更新 Runner':applied?.error?`未生效:${applied.error}`:applied?.revision===this.executionSettings?.proxy().revision?'已同步,启动时校验出口':'待更新'}})}
   private networkSupported(id:string){return !this.executionSettings?.proxy().enabled||this.online.get(id)?.features.includes('vm-egress-v1')===true}
-  private networkCommandAllowed(id:string,kind:RunnerCommand['kind'],payload:Record<string,unknown>){return kind!=='computer.control'||this.networkSupported(id)||!(payload.op==='take'||payload.op==='input'||payload.op==='lifecycle'&&!['stop','remove'].includes(String(payload.action)))}
+  private networkCommandAllowed(id:string,kind:RunnerCommand['kind'],payload:Record<string,unknown>){return kind!=='computer.control'||this.networkSupported(id)||!(payload.op==='autostart'||payload.op==='take'||payload.op==='input'||payload.op==='lifecycle'&&!['stop','remove'].includes(String(payload.action)))}
   async configureNetworks(){await Promise.all(this.records().filter(r=>r.enabled&&this.summary(r).online).map(async r=>{
     if(!this.summary(r).features.includes('vm-egress-v1')){
       if(!this.networkSupported(r.id))await Promise.all([...this.connections].filter(([,c])=>c.runnerId===r.id&&c.computer).map(async([id,c])=>{this.connections.delete(id);c.onDisconnect();await this.request(r.id,'gateway.close',{connectionId:id}).catch(()=>{})}))

+ 74 - 0
tests/live/localVmStartup.live.test.ts

@@ -0,0 +1,74 @@
+// @vitest-environment node
+import {expect,it} from 'vitest'
+import Koa from 'koa'
+import {createServer} from 'node:http'
+import {mkdtemp,mkdir,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {createHash} from 'node:crypto'
+import {DatabaseSync} from 'node:sqlite'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {WorkspaceNodes,type GatewayTarget} from '../../src/server/workspaceGateway'
+import {LocalAuthStore} from '../../src/server/localAuth'
+import {RunnerHub} from '../../src/server/runnerHub'
+import {SharedComputers} from '../../src/server/sharedComputers'
+import {LocalVmService} from '../../src/server/localVm'
+import {saveHostTools} from '../../src/server/hostToolSettings'
+import {ComputerRuntime} from '../../src/runner/worker/gateway'
+import {LocalVmImages} from '../../src/runner/computers/localVm'
+import type {RunnerConfiguration} from '../../src/shared/runner'
+
+class Auth extends LocalAuthStore {
+  override isUserActive(owner:string){return owner==='startup-fixture'}
+  override isAdminActive(owner:string){return this.isUserActive(owner)}
+  override pushAuthorizationVersion(){return 1}
+  override canUseSource(){return true}
+}
+
+it.skipIf(!process.env.YAOYAO_COMPUTER_IMAGE)('starts a real saved VM when the managed service restarts, before any Bot command',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-startup-live-')),store=new WorkspaceStore(home),auth=new Auth(home)
+  const owner='startup-fixture',ownerKey=createHash('sha256').update(owner).digest('hex')
+  const app=new Koa(),server=createServer(app.callback())
+  const target={url:new URL('http://127.0.0.1:1')} as GatewayTarget
+  const hub=new RunnerHub(store,auth,target)
+  app.use(async(ctx,next)=>{try{await next()}catch(error){ctx.status=(error as any).status??500;ctx.body={error:(error as Error).message,code:(error as any).code}}})
+  app.use(hub.middleware())
+  await new Promise<void>(done=>server.listen(0,'127.0.0.1',done))
+  const url=`http://127.0.0.1:${(server.address() as {port:number}).port}`
+  const config={home,upstream:target.url} as any,nodes=new WorkspaceNodes(store,config,target)
+  const registered=hub.enroll(owner,{name:'启动验收',sourceNodeId:'local',allowedProfiles:['default']})
+  const runnerConfig:RunnerConfiguration={protocol:1,runnerId:registered.runner.id,token:registered.token,serverURL:url,hermesURL:target.url.href,allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:process.env.YAOYAO_COMPUTER_IMAGE!,python:'/unused',hermesSource:home,hermesHome:home,network:'none'}}
+  store.put('_system','local-vm-managed','local',{sealed:nodes.seal(runnerConfig)});saveHostTools(home,{vm:true})
+  const bot=store.createAgent(owner,{name:'启动验收 Bot',profile:'default'})
+  const directory=join(home,'runner-state','local-vm',runnerConfig.runnerId);await mkdir(directory,{recursive:true})
+  const db=new DatabaseSync(join(directory,'runner-commands.sqlite3')),seed=new ComputerRuntime(db,runnerConfig,directory),images=new LocalVmImages(seed,db)
+  const service=new LocalVmService(store,auth,nodes,hub,new SharedComputers(store,auth,nodes,hub),config)
+  const spec={id:bot.id,ownerKey,imageId:runnerConfig.computers!.imageId,cwd:'/home/cua/workspace',network:'none' as const}
+  let seeded=false
+  try{
+    await images.ready;await images.idlePolicy(0)
+    await seed.pool.desktop(spec,'create',()=>{})
+    await seed.provider.execute(spec,['/bin/sh','-c','printf startup-proof > startup.txt'],{authorize:()=>{}})
+    await images.close();await seed.controls.close();await seed.pool.close();db.close();seeded=true
+    expect(await seed.provider.inspect(spec)).toBeUndefined()
+    await service.start(url)
+    await expect.poll(async()=>(await seed.provider.inspect(spec))?.running,{timeout:30000,interval:250}).toBe(true)
+    await (service as any).managed.warmup
+    expect((service as any).problem).toBeUndefined()
+    expect((await seed.provider.execute(spec,['cat','startup.txt'],{authorize:()=>{}})).stdout).toBe('startup-proof')
+    const managed=(service as any).managed.agent.computers as ComputerRuntime
+    expect(managed.pool.idleStopMinutes).toBe(0)
+    expect(managed.pool.status(ownerKey)).toEqual([expect.objectContaining({status:'idle',holderIds:[]})])
+    expect(store.list(owner,'turn')).toEqual([])
+    await service.close();expect(await seed.provider.inspect(spec)).toBeUndefined()
+    await service.start(url)
+    await expect.poll(async()=>(await seed.provider.inspect(spec))?.running,{timeout:30000,interval:250}).toBe(true)
+    await (service as any).managed.warmup
+    expect((await seed.provider.execute(spec,['cat','startup.txt'],{authorize:()=>{}})).stdout).toBe('startup-proof')
+  }finally{
+    await service.close();hub.close()
+    if(!seeded){await images.close();await seed.controls.close();await seed.pool.close();db.close()}
+    await new Promise<void>(done=>{server.close(()=>done());server.closeAllConnections()})
+    nodes.close();store.close();await rm(home,{recursive:true,force:true})
+  }
+},120000)

+ 26 - 0
tests/server/localVm.test.ts

@@ -84,6 +84,32 @@ it('refuses lifecycle changes while a human or Agent holds the VM',async()=>{
   await f.runtime.pool.release(lease)
  }finally{await f.close()}
 })
+it('restores only existing stopped desktops with their saved image and cwd, keeping idle policy and capacity',async()=>{
+ const f=await fixture(),id=randomUUID(),meta={agentId:id,environmentId:id,ownerKey:'owner'},spec={id,ownerKey:'owner',imageId:'sha256:'+'b'.repeat(64),cwd:'/home/cua/workspace/saved',network:'none' as const}
+ try{
+  f.runtime.pool.idleStopMinutes=0
+  await f.runtime.restoreDesktop(meta,()=>{})
+  expect(f.runtime.provider.ensure).not.toHaveBeenCalled()
+  await f.runtime.pool.desktop(spec,'create',()=>{});await f.runtime.pool.desktop(spec,'stop',()=>{})
+  vi.mocked(f.runtime.provider.ensure).mockClear()
+  await f.runtime.restoreDesktop(meta,()=>{})
+  expect(f.runtime.provider.ensure).toHaveBeenCalledWith(expect.objectContaining({imageId:spec.imageId,cwd:spec.cwd}),expect.any(Function))
+  expect(f.runtime.pool.status('owner')[0]?.status).toBe('idle');expect(f.runtime.pool.idleStopMinutes).toBe(0)
+  await f.runtime.restoreDesktop(meta,()=>{})
+  expect(f.runtime.provider.ensure).toHaveBeenCalledOnce()
+  const lease=await f.runtime.pool.acquire(f.runtime.pool.definition('owner',id)!,'task',()=>{})
+  await f.runtime.restoreDesktop(meta,()=>{})
+  expect(f.runtime.pool.status('owner')[0]?.holderIds).toEqual(['task'])
+  await f.runtime.pool.release(lease)
+  const other={...spec,id:randomUUID()};f.runtime.pool.limits.concurrent=1
+  await f.runtime.pool.desktop(other,'start',()=>{})
+  await expect(f.runtime.restoreDesktop(meta,()=>{})).rejects.toMatchObject({code:'computer_quota'})
+  await f.runtime.pool.desktop(other,'stop',()=>{})
+  const removing=f.runtime.pool.desktop(spec,'remove',()=>{}),restoring=f.runtime.restoreDesktop(meta,()=>{})
+  await removing;await restoring
+  expect(f.runtime.pool.definition('owner',id)).toBeUndefined()
+ }finally{await f.close()}
+})
 it('allows sharing policy changes with idle desktops but preserves active leases and image maintenance guards',async()=>{
  const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db),spec={id:randomUUID(),ownerKey:'owner',imageId:'sha256:'+'a'.repeat(64)}
  try{

+ 82 - 0
tests/server/localVmStartup.test.ts

@@ -0,0 +1,82 @@
+// @vitest-environment node
+import {expect,it,vi} from 'vitest'
+import {mkdtempSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {LocalVmService} from '../../src/server/localVm'
+import {saveHostTools} from '../../src/server/hostToolSettings'
+
+const runners=vi.hoisted(()=>[] as any[])
+vi.mock('../../src/runner/agent',()=>({RunnerAgent:class {
+  onConnected=()=>{}
+  constructor(readonly config:any){runners.push(this)}
+  async run(signal:AbortSignal){this.onConnected();await new Promise<void>(done=>signal.addEventListener('abort',()=>done(),{once:true}))}
+}}))
+
+function fixture(){
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-vm-startup-')),store=new WorkspaceStore(home)
+  saveHostTools(home,{vm:true})
+  const record={id:randomUUID(),enabled:true,sourceNodeId:'local',sourceOwner:'_system'}
+  const auth={isUserActive:(owner:string)=>owner==='alice'} as any
+  const nodes={open:()=>({runnerId:record.id}),requireSource:vi.fn((_owner:string,agent:any)=>{if(agent.profile==='denied')throw new Error('unauthorized')})} as any
+  const hub={records:()=>[record],computerRunner:()=>record,computer:vi.fn(async(_owner:string,_agent:any,_op:string,_data:any,authorize:()=>void)=>{authorize();return {ok:true}})} as any
+  const shared={reconcileLocalVmGroups:vi.fn(),attachLocalVm:vi.fn()} as any
+  const service=new LocalVmService(store,auth,nodes,hub,shared,{home} as any)
+  store.put('_system','local-vm-managed','local',{sealed:'fixture'})
+  const add=(owner='alice',changes:Record<string,unknown>={})=>{
+    const agent=store.createAgent(owner,{name:`启动测试 ${randomUUID()}`,profile:'default'})
+    Object.assign(agent,changes);store.put(owner,'agent',agent.id,agent);return agent
+  }
+  return {home,store,service,hub,shared,record,add,
+    async settled(){await (service as any).managed?.warmup},
+    async close(){await service.close();store.close();rmSync(home,{recursive:true,force:true})},
+  }
+}
+
+it('starts authorized configured desktops once per service start and deduplicates shared members',async()=>{
+  const f=fixture()
+  try{
+    const group=randomUUID(),first=f.add('alice',{computerEnvironmentId:group})
+    f.add('alice',{computerEnvironmentId:group});const own=f.add()
+    f.add('alice',{archived:true});f.add('alice',{temporaryGoalId:randomUUID()})
+    f.add('alice',{remoteAgentId:randomUUID()});f.add('alice',{nodeId:'remote'})
+    f.add('alice',{profile:'denied'});f.add('bob')
+    await f.service.start('http://127.0.0.1:15300');await f.settled()
+    expect(f.hub.computer.mock.calls.map((c:any[])=>(c[1].computerEnvironmentId??c[1].id)).sort()).toEqual([group,own.id].sort())
+    expect(f.hub.computer).toHaveBeenCalledWith('alice',expect.objectContaining({computerEnvironmentId:first.computerEnvironmentId}),'autostart',{},expect.any(Function))
+    // Reconnection, repeated start(), or polling settings must not undo a stop.
+    runners.at(-1).onConnected()
+    await f.service.start('http://127.0.0.1:15300');await f.settled()
+    expect(f.hub.computer).toHaveBeenCalledTimes(2)
+    await f.service.close();await f.service.start('http://127.0.0.1:15300');await f.settled()
+    expect(f.hub.computer).toHaveBeenCalledTimes(4)
+  }finally{await f.close()}
+})
+
+it('respects the global VM switch and rechecks authorization before each admitted start',async()=>{
+  const f=fixture()
+  try{
+    f.add();saveHostTools(f.home,{vm:false})
+    await f.service.start('http://127.0.0.1:15300');await f.settled()
+    expect(f.hub.computer).not.toHaveBeenCalled()
+    await f.service.close();saveHostTools(f.home,{vm:true})
+    f.hub.computer.mockImplementation(async(_owner:string,_agent:any,_op:string,_data:any,authorize:()=>void)=>{
+      saveHostTools(f.home,{vm:false});expect(authorize).toThrow('虚拟机启动授权已失效')
+    })
+    await f.service.start('http://127.0.0.1:15300');await f.settled()
+    expect(f.hub.computer).toHaveBeenCalledOnce()
+  }finally{await f.close()}
+})
+
+it('keeps Web startup available if one desktop fails, and attempts the remaining desktops',async()=>{
+  const f=fixture()
+  try{
+    f.add();f.add()
+    f.hub.computer.mockRejectedValueOnce(new Error('Docker unavailable'))
+    await expect(f.service.start('http://127.0.0.1:15300')).resolves.toBeUndefined();await f.settled()
+    expect(f.hub.computer).toHaveBeenCalledTimes(2)
+    expect((f.service as any).problem).toContain('未能自动启动')
+  }finally{await f.close()}
+})

+ 19 - 1
tests/server/runner.test.ts

@@ -76,7 +76,7 @@ async function channel(){
 it('blocks old Runner control paths when the global VM proxy is enabled',async()=>{
   hub.executionSettings={proxy:()=>({enabled:true,revision:1})} as any
   hub.controlAllowed=()=>true
-  for(const payload of [{op:'take'},{op:'input'},{op:'lifecycle',action:'start'}])await expect((hub as any).request(config.runnerId,'computer.control',payload)).rejects.toMatchObject({code:'proxy_runner_required'})
+  for(const payload of [{op:'take'},{op:'input'},{op:'autostart'},{op:'lifecycle',action:'start'}])await expect((hub as any).request(config.runnerId,'computer.control',payload)).rejects.toMatchObject({code:'proxy_runner_required'})
   expect(await (runner as any).api('control-check',{controlId:randomUUID()})).toEqual({allowed:false})
   expect((hub as any).networkCommandAllowed(config.runnerId,'computer.control',{op:'lifecycle',action:'stop'})).toBe(true)
 })
@@ -123,6 +123,24 @@ it('admits virtual computer calls only on a computer gateway with an owned sessi
   await expect((runner as any).execute(command('computer.complete'))).rejects.toMatchObject({code:'runner_command_forbidden'})
   expect(rpc).toHaveBeenCalledTimes(3)
 })
+it('waits for local VM recovery before restoring a saved desktop and skips unconfigured targets',async()=>{
+  const original=(runner as any).computers,oldManager=(runner as any).localVm,id=randomUUID()
+  const sync=vi.spyOn(runner as any,'syncNetworkSettings').mockResolvedValue(undefined)
+  let ready!:()=>void
+  const definition=vi.fn(()=>({id})),restoreDesktop=vi.fn(async(_target:any,authorize:()=>void)=>authorize())
+  ;(runner as any).computers={ready:Promise.resolve(),pool:{definition},restoreDesktop}
+  ;(runner as any).localVm={ready:new Promise<void>(done=>{ready=done})}
+  const command={kind:'computer.control',expiresAt:Date.now()+30000,payload:{target:{environmentId:id,agentId:id,ownerKey:'a'.repeat(64)},profile:'default',op:'autostart'}}
+  try{
+    const pending=(runner as any).execute(command)
+    await Promise.resolve();expect(restoreDesktop).not.toHaveBeenCalled()
+    ready();await pending
+    expect(sync).toHaveBeenCalledOnce();expect(restoreDesktop).toHaveBeenCalledWith(command.payload.target,expect.any(Function))
+    definition.mockReturnValue(undefined as any)
+    expect(await (runner as any).execute(command)).toMatchObject({skipped:true})
+    expect(restoreDesktop).toHaveBeenCalledOnce()
+  }finally{ready();sync.mockRestore();(runner as any).computers=original;(runner as any).localVm=oldManager}
+})
 it('requires an upgraded Runner before granting mixed host and VM tools',()=>{
  const id=randomUUID()
  expect(()=>hub.target('owner','local',{environmentId:id,agentId:id,ownerKey:'owner',hostAccess:true})).toThrow('不支持同时使用本机和虚拟机')