Browse Source

fix: 重建前正常关闭桌面应用以保存最新凭据

Samien 1 month ago
parent
commit
94f868007b

+ 1 - 1
docs/computer-maintenance.md

@@ -38,7 +38,7 @@ CLI 默认数据目录为配置旁的 `state/<runnerId>`。App 托管节点的
 
 恢复先验证完整内容并复制到暂存目录;恢复前的数据经校验保留为 `.before-restore-*`,不自动删除。工作区、用户目录和设备标识保留挂载根节点,以兼容 macOS 容器共享文件系统。恢复前写入日志,若恢复中退出,下一次启动同时回退工作区和用户资料,不使用部分恢复结果。旧 protocol 1 备份仍能恢复工作区,但不能恢复当时未备份的用户资料。重建只退休已停止的容器身份,下次从配置镜像创建,保留工作区和用户资料;它不是清空数据。
 
-这是停止电脑后的文件一致备份。浏览器或数据库在强制中断前未落盘的内容不在备份中,不能宣称应用事务快照。
+正常停止前先向桌面应用发出关闭窗口请求,保持显示服务与会话总线可用,给浏览器落盘最新凭据的机会;不自动回答保存提示。等待有上限,无响应时仍按原停止流程处理,紧急执行隔离直接停止。备份是停止电脑后的文件一致备份;强制中断前未落盘的内容不在备份中,不能宣称应用事务快照。
 
 ## 验证状态
 

+ 4 - 2
src/runner/computers/container.ts

@@ -6,6 +6,7 @@ import { z } from 'zod'
 import {recoverWorkspace} from './workspaceRecovery.js'
 import {COMMAND_ROOT,GUEST_COMMAND} from './commandProcess.js'
 import {ComputerUserData,COMPUTER_HOME} from './userData.js'
+import {CLOSE_DESKTOP} from './desktopClose.js'
 
 export const COMPUTER_WORKSPACE = '/home/cua/workspace'
 export const CUA_DRIVER = '/usr/local/libexec/openmausbot/cua-driver'
@@ -276,10 +277,11 @@ export class ContainerComputerProvider implements ComputerProvider {
       }catch(error){await this.stopOwned(spec,state.containerId).catch(()=>{});throw error}
     })
   }
-  private async stopOwned(spec:Spec,id:string) {
+  private async stopOwned(spec:Spec,id:string,graceful=true) {
     const detail=await this.inspectRaw(id)
     if(!detail)return
     this.assertOwned(spec,detail)
+    if(detail.State?.Running&&graceful)await this.run(this.runtime,['exec','--user','1000:1000','--',id,'python3','-c',CLOSE_DESKTOP],{timeout:6000}).catch(()=>{})
     if(detail.State?.Running)await this.run(this.runtime,['stop','--time','10','--',id],{timeout:15000})
     const stopped=await this.inspectRaw(id)
     if(stopped?.State?.Running)throw new ComputerError('computer_stop_uncertain','尚未确认电脑环境已停止')
@@ -389,7 +391,7 @@ export class ContainerComputerProvider implements ComputerProvider {
     },options.lane==='gui'?this.lane(spec.id).gui:undefined)
   }
   /** Escalates to the lifecycle lane so the stop never runs under live operations. */
-  private fence(spec:Spec,id:string){void this.lane(spec.id).write(async()=>{await this.stopOwned(spec,id).catch(()=>{})}).catch(()=>{})}
+  private fence(spec:Spec,id:string){void this.lane(spec.id).write(async()=>{await this.stopOwned(spec,id,false).catch(()=>{})}).catch(()=>{})}
   async installSkill(value:ComputerSpecification,bundle:SkillInstallation,script:string,options:{authorize():void;signal?:AbortSignal}):Promise<{path:string;revision:string}> {
     const spec=this.validateSpecification(value)
     return this.lane(spec.id).read(async()=>{

+ 41 - 0
src/runner/computers/desktopClose.ts

@@ -0,0 +1,41 @@
+/** Ask application windows to close while their display/session bus still live.
+ * Never answer save prompts; the existing bounded container stop remains the
+ * fallback for unresponsive applications. No titles or credentials are read. */
+export const CLOSE_DESKTOP=String.raw`
+import ctypes as c
+import re, subprocess, time
+def windows():
+    result = subprocess.run(['xprop', '-root', '_NET_CLIENT_LIST'], capture_output=True, text=True, timeout=1)
+    return set(int(value, 16) for value in re.findall(r'0x[0-9a-fA-F]+', result.stdout))
+try:
+    x = c.CDLL('libX11.so.6')
+    x.XOpenDisplay.argtypes = [c.c_char_p]; x.XOpenDisplay.restype = c.c_void_p
+    x.XDefaultRootWindow.argtypes = [c.c_void_p]; x.XDefaultRootWindow.restype = c.c_ulong
+    x.XInternAtom.argtypes = [c.c_void_p, c.c_char_p, c.c_int]; x.XInternAtom.restype = c.c_ulong
+    x.XSendEvent.argtypes = [c.c_void_p, c.c_ulong, c.c_int, c.c_long, c.c_void_p]
+    x.XFlush.argtypes = [c.c_void_p]; x.XCloseDisplay.argtypes = [c.c_void_p]
+    display = x.XOpenDisplay(None)
+    if not display: raise RuntimeError('no display')
+    class Client(c.Structure):
+        _fields_ = [('type',c.c_int),('serial',c.c_ulong),('sent',c.c_int),('display',c.c_void_p),('window',c.c_ulong),('message',c.c_ulong),('format',c.c_int),('data',c.c_long*5)]
+    class Event(c.Union):
+        _fields_ = [('client',Client),('padding',c.c_long*24)]
+    pending = set()
+    for window in windows():
+        kind = subprocess.run(['xprop','-id',hex(window),'_NET_WM_WINDOW_TYPE'],capture_output=True,text=True,timeout=1).stdout
+        if '_NET_WM_WINDOW_TYPE_NORMAL' not in kind: continue
+        event = Event(); event.client.type = 33; event.client.sent = 1
+        event.client.display = display; event.client.window = window
+        event.client.message = x.XInternAtom(display,b'_NET_CLOSE_WINDOW',0)
+        event.client.format = 32; event.client.data[1] = 2
+        x.XSendEvent(display,x.XDefaultRootWindow(display),0,(1<<19)|(1<<20),c.byref(event))
+        pending.add(window)
+    x.XFlush(display)
+    deadline = time.monotonic()+4
+    while pending and time.monotonic()<deadline:
+        time.sleep(0.1); pending.intersection_update(windows())
+    if not pending: time.sleep(0.25)
+    x.XCloseDisplay(display)
+except Exception:
+    pass
+`

+ 5 - 3
tests/fixtures/computer_auth.mjs

@@ -4,7 +4,7 @@ import {spawn} from 'node:child_process'
 import {mkdir,readFile,writeFile} from 'node:fs/promises'
 import {join} from 'node:path'
 import {randomBytes,createCipheriv,createDecipheriv} from 'node:crypto'
-const login=process.argv[1]==='login',home=process.env.HOME
+const open=process.argv[1]==='login-open',login=process.argv[1].startsWith('login'),home=process.env.HOME
 const app=join(home,'.config/yaoyao-auth-fixture'),keyring=join(home,'.local/share/keyrings/yaoyao-auth-fixture')
 if(login){
   await mkdir(app,{recursive:true});await mkdir(join(home,'.local/share/keyrings'),{recursive:true})
@@ -22,7 +22,7 @@ const server=createServer((req,res)=>{
   res.setHeader('Content-Type','text/html');res.end('<!doctype html><body>authorization fixture</body>')
 })
 await new Promise(resolve=>server.listen(18765,'127.0.0.1',resolve))
-const browser=spawn('google-chrome',['--headless','--no-sandbox','--disable-dev-shm-usage','--disable-gpu','--no-first-run','--no-default-browser-check','--disable-background-networking','--password-store=basic','--remote-debugging-pipe',`--user-data-dir=${join(home,'.config/yaoyao-auth-browser')}`],{stdio:['ignore','ignore','pipe','pipe','pipe']})
+const browser=spawn('google-chrome',[...(open?[]:['--headless']),'--no-sandbox','--disable-dev-shm-usage','--disable-gpu','--no-first-run','--no-default-browser-check','--disable-background-networking','--password-store=basic','--remote-debugging-pipe',`--user-data-dir=${join(home,'.config/yaoyao-auth-browser')}`],{stdio:['ignore','ignore','pipe','pipe','pipe']})
 let buffer='',id=0,errors='';const waiting=new Map()
 browser.stderr.on('data',chunk=>{errors=(errors+chunk).slice(-1500)})
 browser.stdio[4].on('data',chunk=>{
@@ -54,6 +54,8 @@ try{
     return cookie&&localStorage.getItem('authorization')==='fixture-local'&&token==='fixture-idb';
   })()`},sessionId)
   if(result.exceptionDetails||result.result?.value!==true)throw Error('browser authorization lost: '+JSON.stringify(result))
-  await command('Browser.close');await closed
+  if(open)await writeFile(join(app,'ready'),'ready')
+  else await command('Browser.close')
+  await closed
   console.log(JSON.stringify({browser:true,encryptedApplicationToken:true}))
 }finally{clearTimeout(timeout);browser.kill();server.close()}

+ 17 - 0
tests/live/computerAuth.live.test.ts

@@ -50,3 +50,20 @@ it.skipIf(!process.env.YAOYAO_COMPUTER_IMAGE)('migrates a legacy home and keeps
     expect((await execute(['cat','/etc/machine-id'])).stdout).toBe(identity)
   }finally{await provider.remove(spec);if(derivedImage)await provider.run('docker',['image','rm',derivedImage]);await rm(home,{recursive:true,force:true})}
 },180000)
+it.skipIf(!process.env.YAOYAO_COMPUTER_IMAGE)('flushes a newly authenticated open browser before rebuilding its container',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-open-auth-')),provider=new ContainerComputerProvider('docker',randomUUID(),home)
+  const spec={id:randomUUID(),ownerKey:randomUUID(),imageId:process.env.YAOYAO_COMPUTER_IMAGE!}
+  const script=await readFile(new URL('../fixtures/computer_auth.mjs',import.meta.url),'utf8')
+  try{
+    const state=await provider.ensure(spec,()=>{}),data=join(home,'computer-userdata',spec.id,'home')
+    await provider.health(spec,()=>{})
+    await provider.run('docker',['exec','-d','--user','1000:1000','--env','HOME=/home/cua',state.containerId,'node','--input-type=module','-e',script,'login-open'])
+    await expect.poll(()=>readFile(join(data,'.config/yaoyao-auth-fixture/ready'),'utf8').catch(()=>''),{timeout:35000,interval:200}).toBe('ready')
+    await provider.remove(spec)
+    const preferences=JSON.parse(await readFile(join(data,'.config/yaoyao-auth-browser/Default/Preferences'),'utf8'))
+    expect(preferences.profile.exit_type).toBe('Normal')
+    await provider.ensure(spec,()=>{})
+    const result=await provider.execute(spec,['node','--input-type=module','-e',script,'check'],{authorize:()=>{},timeout:60000})
+    expect(JSON.parse(result.stdout).browser).toBe(true)
+  }finally{await provider.remove(spec);await rm(home,{recursive:true,force:true})}
+},100000)

+ 10 - 0
tests/server/computerProvider.test.ts

@@ -6,6 +6,7 @@ import {tmpdir} from 'node:os'
 import {join} from 'node:path'
 import {ContainerComputerProvider,CUA_DRIVER,type ContainerCommand,type ComputerSpecification} from '../../src/runner/computers/container'
 import {COMMAND_ROOT,GUEST_COMMAND} from '../../src/runner/computers/commandProcess'
+import {CLOSE_DESKTOP} from '../../src/runner/computers/desktopClose'
 const homes:string[]=[]
 afterEach(async()=>{await Promise.all(homes.splice(0).map(home=>rm(home,{recursive:true,force:true})))})
 async function fixture(runtime:'docker'|'podman'='docker',onCommand?:(args:string[])=>Promise<void>){
@@ -194,6 +195,15 @@ it('keeps a shared desktop alive when one holder cancels an unacknowledged opera
   await expect(f.provider.execute(f.spec,['/bin/sh','-c','boom-alone'],{authorize:()=>{}})).rejects.toMatchObject({code:'computer_cancel_uncertain'})
   await new Promise(resolve=>setTimeout(resolve,25))
   expect(stopped).toBe(1)
+  expect(f.calls.some(args=>args.includes(CLOSE_DESKTOP))).toBe(false)
+})
+
+it('requests application shutdown before a planned container stop',async()=>{
+  const f=await fixture();await f.provider.ensure(f.spec,()=>{});f.calls.splice(0)
+  await f.provider.stop(f.spec)
+  const close=f.calls.findIndex(args=>args.includes(CLOSE_DESKTOP)),stop=f.calls.findIndex(args=>args[0]==='stop')
+  expect(close).toBeGreaterThanOrEqual(0);expect(stop).toBeGreaterThan(close)
+  expect(f.calls[close]).toContain('1000:1000')
 })
 
 it('requires a guest stop acknowledgement for a cancelled command and preserves another holder',async()=>{