Pārlūkot izejas kodu

fix: 重建虚拟机时保留用户授权并迁移旧资料

Samien 1 mēnesi atpakaļ
vecāks
revīzija
844f22f82e

+ 10 - 1
bin/lib/service-update.mjs

@@ -1,4 +1,5 @@
-import { execFileSync } from 'node:child_process'
+import { execFile, execFileSync } from 'node:child_process'
+import {promisify} from 'node:util'
 import { randomUUID, createHash } from 'node:crypto'
 import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
 import { homedir } from 'node:os'
@@ -152,6 +153,12 @@ export class LaunchAgentService {
     const record = this.record()
     if (record?.maintenanceProtocol === 1) await localRequest(`${this.url(record)}/desktop/service/quiesce`, record.token, 'DELETE').catch(() => {})
   }
+  async preserveComputerData(finalRoot){
+    const helper=join(finalRoot,'vm-data-migrate.mjs')
+    if(!existsSync(helper))return // Older release packages have no migration hook.
+    try{await promisify(execFile)(join(finalRoot,'node'),[helper,this.home],{timeout:600000,maxBuffer:32768})}
+    catch{throw new Error('虚拟机用户资料迁移未完成,已取消服务切换并保留原容器')}
+  }
   async stop() {
     const pid = this.pid()
     try { execFileSync('launchctl', ['print', `${this.domain}/${this.label}`], { stdio: 'ignore' }); command('launchctl', ['bootout', `${this.domain}/${this.label}`]) }
@@ -272,6 +279,8 @@ export async function transitionService({ home, releaseRoot, driver, finalRoot,
   try {
     if (previous?.wasRunning) await driver.verify(undefined, 5000)
     await driver.quiesce(onProgress, waitForIdleMs)
+    onProgress('正在保留虚拟机用户资料…')
+    await driver.preserveComputerData?.(finalRoot)
     onProgress('正在备份数据并切换 Web 服务…')
     // Persist before stopping; recovery never mistakes a partial backup for a complete snapshot.
     writeJSON(path, journal)

+ 4 - 2
docs/computer-maintenance.md

@@ -19,6 +19,8 @@ node .runner-build/runner-image.mjs import --archive /安全目录/computer.tar
 
 更新时先准备并验证新镜像,停止 Runner 后把配置的 `computers.imageId` 改成新的完整 ID,再启动。资源池只在环境空闲时应用新配置,保留工作区;旧镜像不自动删除。回退使用原镜像 ID,必要时恢复升级前的工作区备份。浏览器资料格式也可能升级,因此不能以镜像回退代替数据回退。
 
+动态桌面在不同镜像第一次启动前自动把工作区和用户资料备份到 Runner 的 `computer-backups/<环境 UUID>/before-image-*`,备份失败不启动。此目录使用相同快照格式,可交给下面的 restore 命令。原镜像仍需保留才能配套回退。
+
 ## 工作区备份、恢复与重建
 
 必须先停止该 Runner。维护工具获取与 Runner 相同的数据目录独占锁,不能和正在执行的 Agent 或人工接管并行运行。它首先处理崩溃遗留的租约和容器;未确认停止的环境不能维护。
@@ -32,9 +34,9 @@ node .runner-build/runner-maintenance.mjs rebuild --config /安全目录/runner.
 
 CLI 默认数据目录为配置旁的 `state/<runnerId>`。App 托管节点的数据目录是 `~/.yaoyao/runner-state/<runnerId>`,维护时用 `--home` 显式指定该目录,并使用原始私有配置文件;App 加密配置不能直接当作 JSON 读取。
 
-备份包含工作文件及持久浏览器资料,不包含运行中的内存、进程、模型密钥、Runner 身份或整个宿主 HOME。目录设为仅当前用户访问,浏览器资料仍可能包含登录状态,应作为私有数据保存。每份备份有账号/环境归属和逐文件校验;普通文件、目录、符号链接保留,不复制运行时 socket。上限为 20 GiB、20 万项。
+新版备份同时包含工作文件和用户资料(完整浏览器配置、软件用户配置、用户钥匙串及设备标识),不包含运行中的内存、进程、Runner 身份或整个宿主 HOME。目录设为仅当前用户访问,资料可能包含登录凭据,应作为私有数据保存。每份备份有账号/环境归属和逐文件校验;普通文件、目录、符号链接保留,不复制运行时 socket。工作区和用户资料各自上限为 20 GiB、20 万项。
 
-恢复先验证完整内容并复制到暂存目录;恢复前的数据经校验保留为 `.before-restore-*`,不自动删除。保留挂载根目录并替换其内容,以兼容 macOS 容器共享文件系统。恢复前写入日志,进程若在恢复中退出,下一次启动先从旧数据副本回退,不使用部分恢复的工作区。重建只退休已停止的容器身份,下次启动从配置镜像重新创建,保留工作区;它不是清空数据。
+恢复先验证完整内容并复制到暂存目录;恢复前的数据经校验保留为 `.before-restore-*`,不自动删除。工作区、用户目录和设备标识保留挂载根节点,以兼容 macOS 容器共享文件系统。恢复前写入日志,若恢复中退出,下一次启动同时回退工作区和用户资料,不使用部分恢复结果。旧 protocol 1 备份仍能恢复工作区,但不能恢复当时未备份的用户资料。重建只退休已停止的容器身份,下次从配置镜像创建,保留工作区和用户资料;它不是清空数据。
 
 这是停止电脑后的文件一致备份。浏览器或数据库在强制中断前未落盘的内容不在备份中,不能宣称应用事务快照。
 

+ 1 - 1
docs/computer-provider.md

@@ -12,7 +12,7 @@
 - 默认 `--network none`,不发布任何端口。容器只有 loopback 网卡,不能直接访问宿主服务或外网。自己的主机名映射到 loopback,保证 XFCE/VNC 在无网络环境下正常启动。可通过专属进程通道启用受控公网代理,仍不增加外部网卡或端口。详见 `docs/computer-network.md`。
 - 桌面通过容器内的 CUA socket 操作;验收已读取真实截图。查看和人工接管已接入账号鉴权及独立控制租约,见 `docs/computer-control.md`。
 - 取消或无法确认的 guest 命令会停止所属私有容器,确认停止后才释放串行执行位置。仅终止 `docker exec` 客户端不能证明 guest 进程已停止。
-- 删除容器保留工作目录,重建后可继续读取工作文件。停止后的工作区备份、校验恢复和重建见 `docs/computer-maintenance.md`;可信共享电脑见 `docs/shared-computers.md`。
+- 删除容器保留工作目录、用户主目录、软件用户配置、钥匙串和设备标识。旧容器删除前先迁移用户目录,失败保留原容器。停止后的备份、校验恢复和重建见 `docs/computer-maintenance.md`;可信共享电脑见 `docs/shared-computers.md`。
 
 权限校验以回调传入,在异步检查与实际执行的前后重验。ComputerPool 在 SQLite 中保存持有者、租约代次、期限与停止状态,默认每个 Runner 同时 2 个环境、总计最多 32 个环境。启动时必须先恢复未确认停止的资源;旧租约先失效,再停止并删除旧容器身份,保留工作区。不确认停止的环境保持占用,不能交给新任务。当前 Provider 与资源池都不作为裸命令接口对浏览器或模型开放,通过 Worker 和任务授权检查使用。
 

+ 10 - 0
docs/local-vm.md

@@ -76,6 +76,16 @@ Hermes 主动发起的审批与澄清 RPC 会转换为 Bot 交互卡;答复使
 
 ## 镜像与持久状态
 
+动态 Docker/Podman 桌面按环境 UUID 在 Runner 的 `computer-userdata/<UUID>/` 保存 `/home/cua`、`/root` 和 machine-id;工作区仍位于 `computer-workspaces/<UUID>/`。浏览器完整配置、软件用户配置及用户钥匙串随环境保留。普通停止、重建、移除实例、服务重启和切换镜像不会删除这些目录;明确删除环境数据(包括临时助手清理)才删除。独立环境各自保存资料,共享环境的成员共享同一份资料。
+
+旧容器首次移除或重建时先确认停止,再复制用户目录,全部完成后才发布持久目录。迁移失败保留旧容器并报错,不会用空白资料继续创建。宿主资料目录仅当前用户可访问,不把文件内容写入日志或模型上下文。已有浏览器工作区路径继续兼容。
+
+配套 macOS 服务更新器在旧 Web 进入维护状态后、退出前运行资料迁移,避免旧版退出时删除尚未迁移的容器。使用外部 Runner 或旧版更新器时,应先停止任务并用新版维护工具备份,再结束旧进程;不要先删除旧容器。
+
+更换镜像首次启动前会在 `computer-backups/<UUID>/before-image-*` 自动备份工作区和用户资料;备份失败不会启动新镜像。备份可通过维护工具恢复,且不会被后续重建覆盖或自动删除。资料目录意外缺失时停止自动重建,避免把原有环境变成空白登录状态。
+
+持久化保留本地授权材料,网站的到期、撤销、设备验证规则仍生效。现有钥匙串密码和加密方式不变,需要交互解锁的钥匙串仍按原方式解锁。安装在 `/usr`、`/opt`、`/var/lib` 等系统目录的软件及授权需要通过镜像或该软件的持久目录安排;更换大版本前应备份,降级时配合恢复数据。Compose 外部桌面的卷仍由部署配置,这里的自动迁移适用于动态桌面。
+
 - 标准桌面沿用现有 XFCE 镜像;Cursor Universal 基于 `public.ecr.aws/k0i0n2g5/cursorenvironments/universal:sand-box-latest` 的固定 amd64 摘要,并添加相同的 CUA 0.20.0 驱动和私有桥接协议。原始镜像不能直接填入现有桌面镜像配置。
 - Cursor 镜像目前只有 Linux amd64;Apple 芯片上的 Docker/Podman 需要支持 amd64 模拟。macOS arm64 App 安装包不代表有 Linux arm64 桌面镜像。
 - 每台独立桌面拥有自己的工作目录和浏览器资料。同一共享桌面只有一个持久镜像选择,所有成员看到同一个选择。

+ 1 - 1
scripts/build-desktop.mjs

@@ -45,7 +45,7 @@ await cp(resolve(root,'build-info.json'),resolve(out,'build-info.json'))
 const manifest = JSON.parse(await readFile(resolve(root, 'release.json'), 'utf8'))
 const service = resolve(out, 'web-service')
 await mkdir(service)
-for (const file of ['server.mjs', 'node_modules', 'ui', 'release.json', 'build-info.json', 'hermes-bots-bridge', 'install-hermes-bridge.py', 'hermes_worker.py', 'profile_skills.py', 'skill_bundle.py', 'guest_proxy.py', 'computer-image', 'third-party']) await cp(resolve(out, file), resolve(service, file), { recursive: true })
+for (const file of ['server.mjs', 'vm-data-migrate.mjs', 'node_modules', 'ui', 'release.json', 'build-info.json', 'hermes-bots-bridge', 'install-hermes-bridge.py', 'hermes_worker.py', 'profile_skills.py', 'skill_bundle.py', 'guest_proxy.py', 'computer-image', 'third-party']) await cp(resolve(out, file), resolve(service, file), { recursive: true })
 for (const file of ['LICENSE', 'NOTICE', 'THIRD_PARTY_NOTICES.md', 'licenses']) await cp(resolve(root, file), resolve(service, file), { recursive: true })
 await cp(resolve(root, 'bin'), resolve(service, 'bin'), { recursive: true })
 await cp(await realpath(process.execPath), resolve(service, 'node'))

+ 1 - 1
scripts/build-runner.mjs

@@ -15,7 +15,7 @@ export async function buildRunner(directory = resolve(import.meta.dirname, '../.
   await cp(resolve(import.meta.dirname,'../deploy/computer'),resolve(directory,'computer-image'),{recursive:true})
   await cp(resolve(import.meta.dirname,'../third-party'),resolve(directory,'third-party'),{recursive:true})
   await build({
-    entryPoints: { 'runner-maintenance':resolve(import.meta.dirname, '../src/runner/computers/maintenanceCli.ts'), 'runner-image':resolve(import.meta.dirname, '../src/runner/computers/imageCli.ts'), runner:resolve(import.meta.dirname, '../src/runner/index.ts'), 'runner-config':resolve(import.meta.dirname, '../src/runner/config.ts') },
+    entryPoints: { 'vm-data-migrate':resolve(import.meta.dirname, '../src/runner/computers/migrateUserData.ts'), 'runner-maintenance':resolve(import.meta.dirname, '../src/runner/computers/maintenanceCli.ts'), 'runner-image':resolve(import.meta.dirname, '../src/runner/computers/imageCli.ts'), runner:resolve(import.meta.dirname, '../src/runner/index.ts'), 'runner-config':resolve(import.meta.dirname, '../src/runner/config.ts') },
     outdir:directory, outExtension:{'.js':'.mjs'}, bundle: true, platform: 'node',
     format: 'esm', target: 'node24', sourcemap: true,external:browserRuntimeExternals,
     banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" },

+ 1 - 1
src/client/components/app/LocalVmSettingsPanel.vue

@@ -31,7 +31,7 @@ function policy(mode:LocalVmMode,maxInstances=state.value?.maxInstances??2){
  void action(()=>apiRequest('/api/app/admin/local-vm/policy',{method:'PUT',body:{requestId:createUuid(),mode,maxInstances}}))
 }
 function manage(agent:WorkspaceAgent,operation:LocalVmAction){
- if(operation==='remove'&&!confirm(`移除“${agent.name}”的虚拟机实例?工作文件和浏览器资料将保留。`))return
+ if(operation==='remove'&&!confirm(`移除“${agent.name}”的虚拟机实例?工作文件、软件配置和登录资料将保留。`))return
  void action(()=>apiRequest(`/api/app/agents/${agent.id}/local-vm/${operation}`,{method:'POST',body:{}}))
 }
 async function cycle(){await refresh();if(!closed)timer=setTimeout(cycle,state.value?.busy?2000:10000)}

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

@@ -136,8 +136,8 @@ async function openPreview(){
  if(target&&frame.value)await openDesktop(target.backend,target.host)
 }
 function action(value:LocalVmAction){
- if(value==='recreate'&&!confirm('重建这台虚拟机?当前桌面程序将关闭,工作文件和浏览器资料会保留。'))return
- if(value==='remove'&&!confirm('移除这台虚拟机实例以切换镜像?工作文件和浏览器资料会保留。'))return
+ if(value==='recreate'&&!confirm('重建这台虚拟机?当前桌面程序将关闭,工作文件、软件配置和登录资料会保留。'))return
+ if(value==='remove'&&!confirm('移除这台虚拟机实例以切换镜像?工作文件、软件配置和登录资料会保留。'))return
  void run(()=>apiRequest(base()+'/'+value,{method:'POST',body:{}}))
 }
 async function cycle(){const version=revision;await refresh();if(!closed&&version===revision)timer=setTimeout(cycle,3000)}
@@ -209,7 +209,7 @@ onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer
       <p class="control-state">{{state.controlMode==='human'?'你或其他操作者正在控制电脑':state.inUse?'机器人正在操作':'仅查看 · 虚拟机空闲'}}</p>
      </template>
      <div v-if="!state.fixedCapacity&&state.container!=='missing'&&!agent.temporaryGoalId" class="vm-actions"><button :disabled="busy||state.inUse" @click="action('stop')">停止虚拟机</button><button :disabled="busy||state.inUse" @click="action('recreate')">重建虚拟机</button><button :disabled="busy||state.inUse" @click="action('remove')">移除实例</button></div>
-     <p class="hint">{{state.fixedCapacity?'桌面由 Compose 创建,数量固定。这里只连接已有共享桌面;工作目录为 /home/cua/workspace。':`重建会保留工作目录和浏览器资料。${vmIdleStopLabel(state.idleStopMinutes)},可在设置中修改。`}}</p>
+     <p class="hint">{{state.fixedCapacity?'桌面由 Compose 创建,数量固定。这里只连接已有共享桌面;工作目录为 /home/cua/workspace。':`重建会保留工作文件、软件配置和登录资料。${vmIdleStopLabel(state.idleStopMinutes)},可在设置中修改。`}}</p>
      <button v-if="isAdmin" class="settings-link" @click="emit('settings')"><AppIcon name="settings" :size="14"/>本地虚拟机设置</button>
     </template>
     </div>

+ 85 - 13
src/runner/computers/container.ts

@@ -5,6 +5,7 @@ import { join, resolve, posix, dirname, basename } from 'node:path'
 import { z } from 'zod'
 import {recoverWorkspace} from './workspaceRecovery.js'
 import {COMMAND_ROOT,GUEST_COMMAND} from './commandProcess.js'
+import {ComputerUserData,COMPUTER_HOME} from './userData.js'
 
 export const COMPUTER_WORKSPACE = '/home/cua/workspace'
 export const CUA_DRIVER = '/usr/local/libexec/openmausbot/cua-driver'
@@ -29,7 +30,7 @@ const schema=z.object({
   id:z.string().uuid(),ownerKey:z.string().min(1).max(256),
   imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),
   cpus:z.number().int().min(1).max(8).default(2),memoryMiB:z.number().int().min(1024).max(16384).default(4096),
-  cwd:z.string().max(4096).refine(value=>!/[\u0000-\u001f,]/.test(value)&&posix.isAbsolute(value)&&posix.normalize(value)===value&&value!=='/'&&!/^\/(?:proc|sys|dev|run|tmp|etc|usr|bin|sbin)(?:\/|$)/.test(value)&&value!=='/home/cua','电脑工作目录不受支持').default(COMPUTER_WORKSPACE),
+  cwd:z.string().max(4096).refine(value=>!/[\u0000-\u001f,]/.test(value)&&posix.isAbsolute(value)&&posix.normalize(value)===value&&value!=='/'&&!/^\/(?:proc|sys|dev|run|tmp|etc|usr|bin|sbin)(?:\/|$)/.test(value)&&!['/home/cua','/root','/var/lib/dbus/machine-id'].includes(value),'电脑工作目录不受支持').default(COMPUTER_WORKSPACE),
   pids:z.number().int().min(64).max(1024).default(512),
 }).strict()
 export type ComputerSpecification=z.input<typeof schema>
@@ -104,12 +105,14 @@ const capabilities=(values:unknown)=>Array.isArray(values)?values.map(value=>Str
 /** A private container per environment. No tools, filesystem operations or
  * process execution fall back to the host. Remote hosts run their own Runner. */
 export class ContainerComputerProvider implements ComputerProvider {
+  readonly userData:ComputerUserData
   readonly fixedCapacity:boolean=false
   private lanes=new Map<string,Lane>()
   readonly capabilities={isolation:'container',shell:true,desktop:true,persistentWorkspace:true,sharedDesktop:false,snapshots:false,network:'none'} as const
   constructor(readonly runtime:'docker'|'podman',readonly runnerId:string,readonly home:string,readonly run:ContainerCommand=command,readonly environment:NodeJS.ProcessEnv=process.env) {
     z.string().uuid().parse(runnerId)
     if(!['docker','podman'].includes(runtime))throw new ComputerError('runtime_unsupported','电脑运行时不受支持')
+    this.userData=new ComputerUserData(home,runtime,run)
   }
   protected lane(id:string):Lane{let lane=this.lanes.get(id);if(!lane){lane=new Lane();this.lanes.set(id,lane)}return lane}
   async verifyRuntime() {
@@ -152,19 +155,21 @@ export class ContainerComputerProvider implements ComputerProvider {
     if(!detail||!/^[a-f0-9]{64}$/.test(String(detail.Id))||Object.entries(this.labels(spec)).filter(([key])=>key!==specLabel).some(([key,value])=>detail.Config?.Labels?.[key]!==value))
       throw new ComputerError('computer_owner_mismatch','电脑实例不属于当前执行节点或配置已变化')
   }
-  private assertConfiguration(spec:Spec,detail:any,workspace:string) {
+  private assertConfiguration(spec:Spec,detail:any,workspace:string,legacy=false) {
     this.assertOwned(spec,detail)
+    if(legacy&&detail.Config?.Labels?.['cn.samien.yaoyao.userdata'])throw new ComputerError('computer_configuration_unsafe','电脑用户资料挂载缺失,已保留原实例')
     const host=detail.HostConfig??{},expectedCaps=this.runtime==='podman'?['SETGID','SETUID','SYS_CHROOT']:['SETGID','SETUID']
     const nanoCPUs=host.NanoCpus??(host.CpuPeriod>0?host.CpuQuota/host.CpuPeriod*1e9:0)
     const ports=Object.entries(host.PortBindings??{}),mounts=detail.Mounts??[]
     const destinations=spec.cwd===COMPUTER_WORKSPACE?[COMPUTER_WORKSPACE]:[COMPUTER_WORKSPACE,spec.cwd]
+    const expected=[...destinations.map(destination=>({source:workspace,destination,rw:true})),...(legacy?[]:this.userData.mounts(resolve(dirname(workspace),'..','computer-userdata',spec.id)))]
     if(detail.Config?.Labels?.[specLabel]!==digest(spec)||detail.Image!==spec.imageId||host.Privileged!==false||host.Memory!==spec.memoryMiB*mib||host.MemorySwap!==spec.memoryMiB*mib
       ||nanoCPUs!==spec.cpus*1e9||host.PidsLimit!==spec.pids||host.IpcMode!=='private'||host.CgroupnsMode!=='private'
       ||(spec.network==='managed-proxy'?![`container:${this.name(spec)}-network`,`container:${this.networkIds.get(spec.id)}`].includes(host.NetworkMode):host.NetworkMode!=='none')||host.PidMode==='host'||host.UsernsMode==='host'||host.UTSMode==='host'
       ||(host.SecurityOpt??[]).some((value:string)=>/^no-new-privileges(?::true)?$/.test(value)||/unconfined/.test(value))
       ||JSON.stringify(capabilities(host.CapAdd))!==JSON.stringify(expectedCaps)||!capabilities(host.CapDrop).includes('ALL')
       ||(host.Devices??[]).length||(host.DeviceRequests??[]).length||host.RestartPolicy?.Name!=='no'
-      ||mounts.length!==destinations.length||mounts.some((mount:any)=>mount.Type!=='bind'||mount.Source!==workspace||!destinations.includes(mount.Destination)||mount.RW!==true)||new Set(mounts.map((mount:any)=>mount.Destination)).size!==destinations.length
+      ||mounts.length!==expected.length||mounts.some((mount:any)=>mount.Type!=='bind'||!expected.some(item=>item.source===mount.Source&&item.destination===mount.Destination&&item.rw===mount.RW))||new Set(mounts.map((mount:any)=>mount.Destination)).size!==expected.length
       ||ports.length!==0)
       throw new ComputerError('computer_configuration_unsafe','电脑实例的隔离、资源或数据目录不符合要求,请先停止并核对')
   }
@@ -174,7 +179,7 @@ export class ContainerComputerProvider implements ComputerProvider {
     return {id:spec.id,containerId:detail.Id,running:detail.State?.Running===true,workspace,isolation:'container'}
   }
   private async migratedContainer(spec:Spec,detail:any,workspace:string) {
-    if(!detail || detail.Mounts?.every((mount:any)=>mount.Source===workspace))return detail
+    if(!detail || detail.Mounts?.some((mount:any)=>mount.Destination===COMPUTER_WORKSPACE&&mount.Source===workspace))return detail
     let root=resolve(this.home)
     while(dirname(root)!==root&&basename(root)!=='.yaoyao')root=dirname(root)
     if(basename(root)!=='.yaoyao')return detail
@@ -182,8 +187,9 @@ export class ContainerComputerProvider implements ComputerProvider {
     try{migration=JSON.parse(await readFile(join(root,'.data-home-migration.json'),'utf8'))}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return detail;throw error}
     if(migration.phase!=='complete'||migration.target!==root||migration.source!==join(dirname(root),'.hermes-yaoyao'))return detail
     const oldWorkspace=migration.source+workspace.slice(root.length)
-    this.assertConfiguration(spec,detail,oldWorkspace)
+    this.assertConfiguration(spec,detail,oldWorkspace,!this.hasUserData(detail))
     if(detail.State?.Running)throw new ComputerError('computer_migration_busy','旧目录的虚拟机仍在运行,请停止后再迁移')
+    await this.userData.capture(spec,detail.Id,true)
     // Preserve the old writable layer as a stopped backup; only the verified name changes.
     await this.run(this.runtime,['rename',detail.Id,`${this.name(spec)}-before-data-move-${detail.Id.slice(0,8)}`])
     return undefined
@@ -192,16 +198,24 @@ export class ContainerComputerProvider implements ComputerProvider {
     await this.verifyRuntime()
     const spec=this.validateSpecification(value),workspace=await this.workspace(spec),detail=await this.migratedContainer(spec,await this.inspectRaw(this.name(spec)),workspace)
     if(detail&&spec.network==='managed-proxy'){const gateway=await this.inspectRaw(this.name(spec)+'-network');this.assertNetwork(spec,gateway);this.networkIds.set(spec.id,gateway.Id)}
+    if(detail&&!this.hasUserData(detail)){
+      this.assertConfiguration(spec,detail,workspace,true)
+      return {id:spec.id,containerId:detail.Id,running:detail.State?.Running===true,workspace,isolation:'container'}
+    }
+    if(detail)await this.userData.existing(spec).then(path=>{if(!path)throw new ComputerError('computer_data_missing','电脑用户资料缺失,已停止自动重建')})
     return detail?this.state(spec,detail,workspace):undefined
   }
-  private args(spec:Spec,workspace:string) {
+  private hasUserData(detail:any){return detail.Mounts?.some((mount:any)=>mount.Destination===COMPUTER_HOME)}
+  private args(spec:Spec,workspace:string,userData?:string) {
     return ['create','--name',this.name(spec),...(spec.network==='managed-proxy'?[]:['--hostname',this.name(spec),'--add-host',`${this.name(spec)}:127.0.0.1`]),
       ...Object.entries(this.labels(spec)).flatMap(([key,value])=>['--label',`${key}=${value}`]),
+      ...(userData?['--label','cn.samien.yaoyao.userdata=1']:[]),
       '--restart','no','--network',spec.network==='managed-proxy'?`container:${this.name(spec)}-network`:'none','--ipc','private','--cgroupns','private',
       '--memory',`${spec.memoryMiB}m`,'--memory-swap',`${spec.memoryMiB}m`,'--cpus',String(spec.cpus),'--pids-limit',String(spec.pids),
       '--shm-size','512m','--cap-drop','ALL','--cap-add','SETUID','--cap-add','SETGID',
       ...(this.runtime==='podman'?['--userns','keep-id:uid=1000,gid=1000','--user','root','--cap-add','SYS_CHROOT']:[]),
       ...[...new Set([COMPUTER_WORKSPACE,spec.cwd])].flatMap(destination=>['--mount',`type=bind,source=${workspace},target=${destination}${this.runtime==='podman'?',relabel=private':''}`]),
+      ...(userData?this.userData.mounts(userData).flatMap(mount=>['--mount',`type=bind,source=${mount.source},target=${mount.destination}${mount.rw?'':',readonly'}${this.runtime==='podman'?',relabel=private':''}`]):[]),
       ...(spec.network==='public-proxy'?['--env','HTTP_PROXY=http://127.0.0.1:3128','--env','HTTPS_PROXY=http://127.0.0.1:3128','--env','http_proxy=http://127.0.0.1:3128','--env','https_proxy=http://127.0.0.1:3128','--env','NO_PROXY=localhost,127.0.0.1,::1','--env','no_proxy=localhost,127.0.0.1,::1']:[]),
       '--env',`VNC_PW=${randomBytes(24).toString('base64url')}`,spec.imageId]
   }
@@ -212,6 +226,17 @@ export class ContainerComputerProvider implements ComputerProvider {
       const workspace=await this.workspace(spec)
       if(spec.network==='managed-proxy')await this.ensureNetwork(spec,authorize)
       let detail=await this.migratedContainer(spec,await this.inspectRaw(this.name(spec)),workspace)
+      if(detail&&!this.hasUserData(detail)){
+        // Verify the old boundary before copying private data. A single legacy
+        // flag is relaxed only for this one-time migration.
+        const checked={...detail,HostConfig:{...detail.HostConfig,SecurityOpt:(detail.HostConfig?.SecurityOpt??[]).filter((value:string)=>!/^no-new-privileges(?::true)?$/.test(value))}}
+        this.assertConfiguration(spec,checked,workspace,true)
+        await this.stopOwned(spec,detail.Id)
+        await this.userData.capture(spec,detail.Id,true)
+        authorize()
+        await this.run(this.runtime,['rm','--',detail.Id])
+        detail=undefined
+      }
       if(detail?.HostConfig?.SecurityOpt?.some((value:string)=>/^no-new-privileges(?::true)?$/.test(value))) {
         await this.stopOwned(spec,detail.Id)
         await this.run(this.runtime,['rm','--',detail.Id])
@@ -222,12 +247,26 @@ export class ContainerComputerProvider implements ComputerProvider {
         if(image?.Id!==spec.imageId||image.Config?.Labels?.['com.openmausbot.cua-driver']!=='0.20.0'||image.Config?.Labels?.['com.openmausbot.image-layer']!=='5')
           throw new ComputerError('computer_image_incompatible','电脑镜像缺少已验证的驱动,请先安装兼容镜像')
         authorize()
-        const created=await this.run(this.runtime,this.args(spec,workspace))
+        let userData=await this.userData.existing(spec)
+        if(!userData){
+          // Seed from an unstarted image container, so its desktop defaults and
+          // user-installed tools are available before the home is over-mounted.
+          const seed=await this.run(this.runtime,this.args(spec,workspace))
+          const seedId=seed.stdout.trim(),seedDetail=await this.inspectRaw(seedId)
+          this.assertConfiguration(spec,seedDetail,workspace,true)
+          await this.stopOwned(spec,seedId)
+          userData=await this.userData.capture(spec,seedId,false)
+          await this.run(this.runtime,['rm','--',seedId])
+        }
+        await this.userData.beforeImageChange(spec,workspace)
+        authorize()
+        const created=await this.run(this.runtime,this.args(spec,workspace,userData))
         const id=created.stdout.trim()
         if(!/^[a-f0-9]{64}$/.test(id))throw new ComputerError('computer_creation_uncertain','电脑创建结果不确定,请核对实例')
         detail=await this.inspectRaw(id)
       }
       let state=this.state(spec,detail,workspace)
+      if(!await this.userData.existing(spec))throw new ComputerError('computer_data_missing','电脑用户资料缺失,已停止自动重建')
       authorize()
       if(!state.running)await this.run(this.runtime,['start','--',state.containerId])
       try {
@@ -249,12 +288,40 @@ export class ContainerComputerProvider implements ComputerProvider {
     const spec=this.validateSpecification(value)
     return this.lane(spec.id).write(async()=>{await this.verifyRuntime();const detail=await this.inspectRaw(this.name(spec));if(detail){this.assertOwned(spec,detail);await this.stopOwned(spec,detail.Id)}})
   }
+  /** Called by the updater while the old Web service is quiesced. Old versions
+   * remove containers on shutdown, so waiting until the new service boots loses
+   * their writable homes. Leave the verified source intact until shutdown. */
+  async preserveUserData(value:ComputerSpecification):Promise<boolean>{
+    const spec=this.validateSpecification(value)
+    return this.lane(spec.id).write(async()=>{
+      if(await this.userData.existing(spec))return false
+      await this.verifyRuntime()
+      const detail=await this.inspectRaw(this.name(spec))
+      if(!detail)return false
+      this.assertOwned(spec,detail)
+      if(this.hasUserData(detail)){if(!await this.userData.existing(spec))throw new Error('电脑用户资料缺失');return false}
+      this.assertConfiguration(spec,detail,await this.workspace(spec),true)
+      await this.stopOwned(spec,detail.Id)
+      await this.userData.capture(spec,detail.Id,true)
+      return true
+    })
+  }
   async remove(value:ComputerSpecification):Promise<void> {
     const spec=this.validateSpecification(value)
     return this.lane(spec.id).write(async()=>{
       await this.verifyRuntime()
       const detail=await this.inspectRaw(this.name(spec))
-      if(detail){this.assertOwned(spec,detail);await this.stopOwned(spec,detail.Id);await this.run(this.runtime,['rm','--',detail.Id])}
+      if(detail){
+        this.assertOwned(spec,detail);await this.stopOwned(spec,detail.Id)
+        // Shutdown, cancellation and image changes also pass through remove.
+        // Preserve legacy homes before any of those paths delete a container.
+        if(!this.hasUserData(detail)){
+          const workspace=await this.workspace(spec)
+          if((detail.Mounts??[]).some((mount:any)=>mount.Type!=='bind'||mount.Source!==workspace))throw new ComputerError('computer_configuration_unsafe','旧电脑的数据挂载不符合要求,已保留原容器')
+          await this.userData.capture(spec,detail.Id,true)
+        }
+        await this.run(this.runtime,['rm','--',detail.Id])
+      }
       if(spec.network==='managed-proxy'){const gateway=await this.inspectRaw(this.name(spec)+'-network');if(gateway){this.assertNetwork(spec,gateway);await this.run(this.runtime,['rm','-f','--',gateway.Id])}this.networkIds.delete(spec.id)}
       // Persistent workspace deletion is a separate, explicit operation.
     })
@@ -351,11 +418,16 @@ export class ContainerComputerProvider implements ComputerProvider {
     return this.lane(spec.id).write(async()=>{
       if(await this.inspectRaw(this.name(spec)))throw new ComputerError('computer_busy','删除工作区前必须移除电脑实例')
       const base=await realpath(resolve(this.home,'computer-workspaces')).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
-      if(!base)return
-      const path=join(base,spec.id),info=await lstat(path).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
-      if(!info)return
-      if(!info.isDirectory()||info.isSymbolicLink()||await realpath(path)!==path)throw new ComputerError('computer_path_unsafe','拒绝删除非预期的电脑工作区')
-      await rm(path,{recursive:true})
+      const userData=await this.userData.existing(spec)
+      if(base){
+        const path=join(base,spec.id),info=await lstat(path).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
+        if(info){
+          if(!info.isDirectory()||info.isSymbolicLink()||await realpath(path)!==path)throw new ComputerError('computer_path_unsafe','拒绝删除非预期的电脑工作区')
+          await rm(path,{recursive:true})
+        }
+      }
+      if(userData)await rm(userData,{recursive:true})
+      await rm(this.userData.path(spec.id)+'.required',{force:true})
     })
   }
   private assertNetwork(spec:Spec,gateway:any){

+ 42 - 0
src/runner/computers/dataSnapshot.ts

@@ -0,0 +1,42 @@
+import {mkdir,readdir,lstat,readlink,writeFile,rm} from 'node:fs/promises'
+import {join,resolve,relative} from 'node:path'
+import {createHash} from 'node:crypto'
+import {createReadStream} from 'node:fs'
+import {z} from 'zod'
+import {copyWorkspace} from './workspaceRecovery.js'
+import type {ComputerSpecification} from './container.js'
+async function fileSHA256(path:string){const hash=createHash('sha256');for await(const bytes of createReadStream(path))hash.update(bytes);return hash.digest('hex')}
+const filesSchema=z.array(z.object({path:z.string(),kind:z.enum(['file','directory','symlink']),sha256:z.string().optional(),target:z.string().optional()}))
+export const snapshotSchema=z.object({protocol:z.union([z.literal(1),z.literal(2)]),environmentId:z.string().uuid(),ownerKey:z.string(),imageId:z.string(),createdAt:z.number(),files:filesSchema,userDataFiles:filesSchema.optional()}).strict()
+type Snapshot=z.infer<typeof snapshotSchema>
+export async function inventory(root:string):Promise<Snapshot['files']>{
+    const files:Snapshot['files']=[];let bytes=0
+    const visit=async(directory:string)=>{
+      for(const name of (await readdir(directory)).sort()){
+        const path=join(directory,name),item=await lstat(path),key=relative(root,path)
+        if(files.length>=200000)throw new Error('工作区文件数量超过备份上限')
+        if(item.isSymbolicLink())files.push({path:key,kind:'symlink',target:await readlink(path)})
+        else if(item.isDirectory()){files.push({path:key,kind:'directory'});await visit(path)}
+        else if(item.isFile()){bytes+=item.size;if(bytes>20*1024**3)throw new Error('工作区超过 20 GiB 备份上限');files.push({path:key,kind:'file',sha256:await fileSHA256(path)})}
+        // Runtime sockets are intentionally omitted; applications recreate them.
+      }
+    }
+    await visit(root);return files
+  }
+export async function createSnapshot(spec:ComputerSpecification,source:string,userData:string,destination:string){
+    const target=resolve(destination)
+    const hasData=await lstat(userData).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
+    if(hasData&&(!hasData.isDirectory()||hasData.isSymbolicLink()))throw new Error('用户资料目录不安全')
+    if([source,userData].some(path=>target===resolve(path)||target.startsWith(resolve(path)+'/')))throw new Error('备份必须放在电脑工作区和用户资料之外')
+    // Reserve the destination. Never overwrite an earlier snapshot.
+    await mkdir(target,{mode:0o700})
+    try{
+      const files=await inventory(source),userDataFiles=hasData?await inventory(userData):undefined
+      await copyWorkspace(source,join(target,'workspace'))
+      if(hasData)await copyWorkspace(userData,join(target,'userdata'))
+      if(JSON.stringify(await inventory(join(target,'workspace')))!==JSON.stringify(files)||hasData&&JSON.stringify(await inventory(join(target,'userdata')))!==JSON.stringify(userDataFiles))throw new Error('备份复制校验失败,未发布快照')
+      const manifest:Snapshot={protocol:2,environmentId:spec.id,ownerKey:spec.ownerKey,imageId:spec.imageId,createdAt:Date.now(),files,...(userDataFiles?{userDataFiles}:{})}
+      await writeFile(join(target,'snapshot.json'),JSON.stringify(manifest,null,2)+'\n',{flag:'wx',mode:0o600})
+      return {snapshot:target,files:manifest.files.length}
+    }catch(error){await rm(target,{recursive:true,force:true});throw error}
+  }

+ 35 - 39
src/runner/computers/maintenance.ts

@@ -1,13 +1,10 @@
-import {mkdir,readdir,lstat,readlink,readFile,writeFile,rm,realpath} from 'node:fs/promises'
-import {join,resolve,dirname,relative,basename} from 'node:path'
+import {mkdir,lstat,readFile,writeFile,rm,realpath} from 'node:fs/promises'
+import {join,resolve,dirname,basename} from 'node:path'
 import {randomUUID} from 'node:crypto'
-import {z} from 'zod'
-import {fileSHA256} from './images.js'
-import {recoverWorkspace,copyWorkspace,replaceWorkspaceContents} from './workspaceRecovery.js'
+import {snapshotSchema,inventory,createSnapshot} from './dataSnapshot.js'
+import {recoverWorkspace,copyWorkspace,replaceWorkspaceContents,replaceUserDataContents} from './workspaceRecovery.js'
 import type {ComputerPool} from './pool.js'
 import type {ContainerComputerProvider,ComputerSpecification} from './container.js'
-const schema=z.object({protocol:z.literal(1),environmentId:z.string().uuid(),ownerKey:z.string(),imageId:z.string(),createdAt:z.number(),files:z.array(z.object({path:z.string(),kind:z.enum(['file','directory','symlink']),sha256:z.string().optional(),target:z.string().optional()}))}).strict()
-type Snapshot=z.infer<typeof schema>
 /** Offline maintenance: caller holds the exact Runner OS lock throughout. */
 export class ComputerMaintenance {
   constructor(readonly pool:ComputerPool,readonly provider:ContainerComputerProvider,readonly home:string){}
@@ -21,50 +18,49 @@ export class ComputerMaintenance {
     if(!detail.isDirectory()||detail.isSymbolicLink()||await realpath(path)!==join(root,spec.id))throw new Error('电脑工作区路径不安全')
     return path
   }
-  private async inventory(root:string):Promise<Snapshot['files']>{
-    const files:Snapshot['files']=[];let bytes=0
-    const visit=async(directory:string)=>{
-      for(const name of (await readdir(directory)).sort()){
-        const path=join(directory,name),item=await lstat(path),key=relative(root,path)
-        if(files.length>=200000)throw new Error('工作区文件数量超过备份上限')
-        if(item.isSymbolicLink())files.push({path:key,kind:'symlink',target:await readlink(path)})
-        else if(item.isDirectory()){files.push({path:key,kind:'directory'});await visit(path)}
-        else if(item.isFile()){bytes+=item.size;if(bytes>20*1024**3)throw new Error('工作区超过 20 GiB 备份上限');files.push({path:key,kind:'file',sha256:await fileSHA256(path)})}
-        // Runtime sockets are intentionally omitted; applications recreate them.
-      }
-    }
-    await visit(root);return files
-  }
   async backup(spec:ComputerSpecification,destination:string){
-    const source=await this.stopped(spec),target=resolve(destination)
-    if(target===resolve(source)||target.startsWith(resolve(source)+'/'))throw new Error('备份必须放在电脑工作区之外')
-    // Reserve the destination. Never overwrite an earlier snapshot.
-    await mkdir(target,{mode:0o700})
-    try{
-      await copyWorkspace(source,join(target,'workspace'))
-      const manifest:Snapshot={protocol:1,environmentId:spec.id,ownerKey:spec.ownerKey,imageId:spec.imageId,createdAt:Date.now(),files:await this.inventory(join(target,'workspace'))}
-      await writeFile(join(target,'snapshot.json'),JSON.stringify(manifest,null,2)+'\n',{flag:'wx',mode:0o600})
-      return {snapshot:target,files:manifest.files.length}
-    }catch(error){await rm(target,{recursive:true,force:true});throw error}
+    return createSnapshot(spec,await this.stopped(spec),join(this.home,'computer-userdata',spec.id),destination)
   }
   async restore(spec:ComputerSpecification,snapshot:string){
-    const workspace=await this.stopped(spec),source=resolve(snapshot),manifest=schema.parse(JSON.parse(await readFile(join(source,'snapshot.json'),'utf8')))
+    const workspace=await this.stopped(spec),source=resolve(snapshot),manifest=snapshotSchema.parse(JSON.parse(await readFile(join(source,'snapshot.json'),'utf8')))
     if(manifest.ownerKey!==spec.ownerKey||manifest.environmentId!==spec.id)throw new Error('备份不属于当前电脑及账号')
     const sourceWorkspace=join(source,'workspace')
     if((await lstat(sourceWorkspace)).isSymbolicLink())throw new Error('备份工作区不能是符号链接')
-    if(JSON.stringify(await this.inventory(sourceWorkspace))!==JSON.stringify(manifest.files))throw new Error('备份内容校验失败,未替换工作区')
+    if(JSON.stringify(await inventory(sourceWorkspace))!==JSON.stringify(manifest.files))throw new Error('备份内容校验失败,未替换工作区')
+    const sourceData=join(source,'userdata'),dataBase=join(this.home,'computer-userdata'),data=join(dataBase,spec.id)
+    if(manifest.userDataFiles){
+      const item=await lstat(sourceData)
+      if(!item.isDirectory()||item.isSymbolicLink()||JSON.stringify(await inventory(sourceData))!==JSON.stringify(manifest.userDataFiles))throw new Error('用户资料备份校验失败')
+    }
     const staging=join(dirname(workspace),`.restore-${randomUUID()}`),previous=join(dirname(workspace),`.before-restore-${spec.id}-${randomUUID()}`)
+    const dataStaging=join(dataBase,`.restore-${randomUUID()}`),dataPrevious=join(dataBase,`.before-restore-${spec.id}-${randomUUID()}`)
+    let hadData=false
     try{
       await copyWorkspace(sourceWorkspace,staging)
-      if(JSON.stringify(await this.inventory(staging))!==JSON.stringify(manifest.files))throw new Error('恢复暂存校验失败')
+      if(JSON.stringify(await inventory(staging))!==JSON.stringify(manifest.files))throw new Error('恢复暂存校验失败')
       await copyWorkspace(workspace,previous)
-      if(JSON.stringify(await this.inventory(previous))!==JSON.stringify(await this.inventory(workspace)))throw new Error('恢复前数据备份校验失败')
+      if(JSON.stringify(await inventory(previous))!==JSON.stringify(await inventory(workspace)))throw new Error('恢复前数据备份校验失败')
+      if(manifest.userDataFiles){
+        await mkdir(dataBase,{recursive:true,mode:0o700})
+        await copyWorkspace(sourceData,dataStaging)
+        if(JSON.stringify(await inventory(dataStaging))!==JSON.stringify(manifest.userDataFiles))throw new Error('用户资料恢复暂存校验失败')
+        const existing=await lstat(data).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
+        hadData=!!existing
+        if(existing){
+          if(!existing.isDirectory()||existing.isSymbolicLink())throw new Error('用户资料目录不安全')
+          await copyWorkspace(data,dataPrevious)
+          if(JSON.stringify(await inventory(dataPrevious))!==JSON.stringify(await inventory(data)))throw new Error('恢复前用户资料备份校验失败')
+        }
+      }
       const journal=join(dirname(workspace),`.restore-${spec.id}.json`)
-      await writeFile(journal,JSON.stringify({protocol:1,mode:'copy',staging:basename(staging),previous:basename(previous)}),{flag:'wx',mode:0o600})
-      try{await replaceWorkspaceContents(staging,workspace)}catch(error){await recoverWorkspace(dirname(workspace),spec.id);throw error}
+      await writeFile(journal,JSON.stringify({protocol:1,mode:'copy',staging:basename(staging),previous:basename(previous),...(manifest.userDataFiles?{userData:{previous:hadData?basename(dataPrevious):null,staging:basename(dataStaging)}}:{})}),{flag:'wx',mode:0o600})
+      try{
+        await replaceWorkspaceContents(staging,workspace)
+        if(manifest.userDataFiles){if(hadData)await replaceUserDataContents(dataStaging,data);else await copyWorkspace(dataStaging,data)}
+      }catch(error){await recoverWorkspace(dirname(workspace),spec.id);throw error}
       await rm(journal)
-      return {restored:workspace,previousWorkspace:previous,imageId:spec.imageId}
-    }finally{await rm(staging,{recursive:true,force:true})}
+      return {restored:workspace,previousWorkspace:previous,imageId:spec.imageId,userDataRestored:!!manifest.userDataFiles,...(hadData?{previousUserData:dataPrevious}:{})}
+    }finally{await rm(staging,{recursive:true,force:true});await rm(dataStaging,{recursive:true,force:true})}
   }
   async rebuild(spec:ComputerSpecification){const workspace=await this.stopped(spec);return {workspace,rebuildOnNextStart:true,imageId:spec.imageId}}
 }

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

@@ -0,0 +1,41 @@
+import {existsSync,readFileSync} from 'node:fs'
+import {join,resolve} from 'node:path'
+import {fileURLToPath} from 'node:url'
+import {createDecipheriv} from 'node:crypto'
+import {DatabaseSync} from 'node:sqlite'
+import {z} from 'zod'
+import {ContainerComputerProvider} from './container.js'
+
+/** Read-only metadata access. The updater must first quiesce the managed Web. */
+export async function preserveManagedVmData(home:string){
+  const path=join(home,'workspace.sqlite3')
+  if(!existsSync(path))return {preserved:0}
+  const db=new DatabaseSync(path,{readOnly:true})
+  let configuration:{runnerId:string;computers?:{runtime:'docker'|'podman'}}|undefined
+  try{
+    if(!db.prepare("SELECT 1 FROM sqlite_master WHERE name='workspace_entities'").get())return {preserved:0}
+    const row=db.prepare("SELECT data FROM workspace_entities WHERE owner='_system' AND kind='local-vm-managed' AND id='local'").get() as {data:string}|undefined
+    if(!row)return {preserved:0}
+    const sealed=Buffer.from(JSON.parse(row.data).sealed,'base64'),cipher=createDecipheriv('aes-256-gcm',readFileSync(join(home,'workspace-key.bin')),sealed.subarray(0,12))
+    cipher.setAuthTag(sealed.subarray(12,28))
+    configuration=z.object({runnerId:z.string().uuid(),computers:z.object({runtime:z.enum(['docker','podman'])}).optional()}).parse(JSON.parse(Buffer.concat([cipher.update(sealed.subarray(28)),cipher.final()]).toString()))
+  }finally{db.close()}
+  if(!configuration?.computers)return {preserved:0}
+  const directory=join(home,'runner-state','local-vm',configuration.runnerId),database=join(directory,'runner-commands.sqlite3')
+  if(!existsSync(database))return {preserved:0}
+  const state=new DatabaseSync(database,{readOnly:true})
+  try{
+    if(!state.prepare("SELECT 1 FROM sqlite_master WHERE name='computer_environments'").get())return {preserved:0}
+    const rows=state.prepare('SELECT value FROM computer_environments').all() as {value:string}[]
+    const entries=rows.map(row=>JSON.parse(row.value))
+    if(entries.some(entry=>['active','preparing','stopping'].includes(entry.status)))throw new Error('虚拟机仍在使用,不能迁移用户资料')
+    const provider=new ContainerComputerProvider(configuration.computers.runtime,configuration.runnerId,directory)
+    let preserved=0
+    for(const entry of entries)if(await provider.preserveUserData(entry.spec))preserved++
+    return {preserved}
+  }finally{state.close()}
+}
+if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){
+  try{if(!process.argv[2])throw new Error();console.log(JSON.stringify(await preserveManagedVmData(resolve(process.argv[2]))))}
+  catch{console.error('虚拟机资料迁移未完成,已保留原服务和容器;请检查运行状态与可用空间后重试');process.exitCode=1}
+}

+ 99 - 0
src/runner/computers/userData.ts

@@ -0,0 +1,99 @@
+import {mkdir,lstat,realpath,readFile,writeFile,rename,rm,chmod} from 'node:fs/promises'
+import {join,resolve} from 'node:path'
+import {createHash,randomBytes,randomUUID} from 'node:crypto'
+import {realpathSync} from 'node:fs'
+import type {ContainerCommand,ComputerSpecification} from './container.js'
+import {createSnapshot} from './dataSnapshot.js'
+
+export const COMPUTER_HOME='/home/cua'
+const owner=(spec:ComputerSpecification)=>createHash('sha256').update(spec.ownerKey).digest('hex')
+
+/** Private, environment-scoped application data. Never mount the host user's HOME. */
+export class ComputerUserData {
+  constructor(readonly home:string,readonly runtime:'docker'|'podman',readonly run:ContainerCommand){}
+  path(id:string){return resolve(realpathSync(this.home),'computer-userdata',id)}
+  async existing(spec:ComputerSpecification):Promise<string|undefined>{
+    const path=this.path(spec.id)
+    const stat=await lstat(path).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
+    if(!stat){
+      const required=await lstat(path+'.required').catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
+      if(required)throw new Error('电脑用户资料缺失,已停止自动重建;请恢复用户资料备份')
+      return undefined
+    }
+    if(!stat.isDirectory()||stat.isSymbolicLink()||await realpath(path)!==path)throw new Error('电脑用户资料目录不安全')
+    const manifest=JSON.parse(await readFile(join(path,'identity.json'),'utf8'))
+    if(manifest.protocol!==1||manifest.environmentId!==spec.id||manifest.owner!==owner(spec))throw new Error('电脑用户资料归属不匹配')
+    for(const name of ['home','root','machine-id']){
+      const item=await lstat(join(path,name))
+      if(item.isSymbolicLink()||(name==='machine-id'?!item.isFile():!item.isDirectory()))throw new Error('电脑用户资料挂载路径不安全')
+    }
+    if(!/^[a-f0-9]{32}\n?$/.test(await readFile(join(path,'machine-id'),'utf8')))throw new Error('电脑设备标识无效')
+    return path
+  }
+  async beforeImageChange(spec:ComputerSpecification,workspace:string){
+    const path=await this.existing(spec)
+    if(!path)throw new Error('电脑用户资料缺失')
+    const manifest=JSON.parse(await readFile(join(path,'identity.json'),'utf8'))
+    if(manifest.imageId===spec.imageId)return
+    const base=resolve(realpathSync(this.home),'computer-backups',spec.id)
+    await mkdir(base,{recursive:true,mode:0o700})
+    if(await realpath(base)!==base)throw new Error('电脑备份目录不安全')
+    // A different image may upgrade browser/app databases on its first boot.
+    // Keep the old image's complete, verified data before allowing that boot.
+    const snapshot=await createSnapshot({...spec,imageId:manifest.imageId??spec.imageId},workspace,path,join(base,`before-image-${Date.now()}-${randomUUID()}`))
+    const temporary=join(path,`.identity-${randomUUID()}.json`)
+    await writeFile(temporary,JSON.stringify({...manifest,imageId:spec.imageId,previousSnapshot:snapshot.snapshot})+'\n',{mode:0o600,flag:'wx'})
+    await rename(temporary,join(path,'identity.json'))
+  }
+  mounts(path:string){return [
+    {source:join(path,'home'),destination:COMPUTER_HOME,rw:true},
+    {source:join(path,'root'),destination:'/root',rw:true},
+    {source:join(path,'machine-id'),destination:'/etc/machine-id',rw:false},
+    {source:join(path,'machine-id'),destination:'/var/lib/dbus/machine-id',rw:false},
+  ]}
+  /** The caller has stopped and verified the source. Publish only a complete copy. */
+  async capture(spec:ComputerSpecification,containerId:string,legacy:boolean){
+    const existing=await this.existing(spec)
+    if(existing)return existing
+    const base=resolve(realpathSync(this.home),'computer-userdata')
+    await mkdir(base,{recursive:true,mode:0o700})
+    if(await realpath(base)!==base)throw new Error('电脑用户资料父目录不安全')
+    const staging=join(base,`.migrate-${spec.id}-${randomUUID()}`)
+    await mkdir(staging,{mode:0o700})
+    try{
+      // cp also works for stopped containers. Do not follow guest symlinks or
+      // expose file contents through the Runner command/output channel.
+      for(const [guest,name] of [[COMPUTER_HOME,'home'],['/root','root']] as const){
+        await mkdir(join(staging,name),{mode:0o700})
+        await this.run(this.runtime,['cp',`${containerId}:${guest}/.`,join(staging,name)],{timeout:300000})
+      }
+      // The workspace has its own mount; never retain a second stale copy.
+      await rm(join(staging,'home','workspace'),{recursive:true,force:true})
+      await mkdir(join(staging,'home','workspace'),{mode:0o700})
+      let identity:string|undefined
+      if(legacy){
+        for(const guest of ['/etc/machine-id','/var/lib/dbus/machine-id']){
+          try{
+            await this.run(this.runtime,['cp',`${containerId}:${guest}`,join(staging,'machine-id')],{timeout:15000})
+            const item=await lstat(join(staging,'machine-id'))
+            if(item.isFile()&&!item.isSymbolicLink()){
+              const copied=await readFile(join(staging,'machine-id'),'utf8')
+              if(/^[a-f0-9]{32}\n?$/.test(copied)){identity=copied;break}
+            }
+          }catch(error){if(!/could not find|no such file|does not exist/i.test(String((error as any).stderr??'')))throw error}
+          await rm(join(staging,'machine-id'),{force:true})
+        }
+      }
+      await writeFile(join(staging,'machine-id'),identity??randomBytes(16).toString('hex')+'\n',{mode:0o444})
+      await chmod(join(staging,'machine-id'),0o444)
+      await writeFile(join(staging,'identity.json'),JSON.stringify({protocol:1,environmentId:spec.id,owner:owner(spec),imageId:spec.imageId,sourceContainer:containerId,createdAt:Date.now()})+'\n',{mode:0o600,flag:'wx'})
+      await rename(staging,this.path(spec.id))
+      await writeFile(this.path(spec.id)+'.required',JSON.stringify({environmentId:spec.id,owner:owner(spec)})+'\n',{mode:0o600,flag:'wx'})
+      return (await this.existing(spec))!
+    }catch{
+      // Runtime errors can contain private filenames. Fail closed and retain the
+      // old container; no empty profile may silently replace an existing login.
+      throw new Error('电脑用户资料迁移失败,原容器已保留;请检查磁盘空间及容器运行时后重试')
+    }finally{await rm(staging,{recursive:true,force:true})}
+  }
+}

+ 46 - 5
src/runner/computers/workspaceRecovery.ts

@@ -1,4 +1,4 @@
-import {readFile,rename,rm,lstat,readdir,cp} from 'node:fs/promises'
+import {readFile,writeFile,chmod,rename,rm,lstat,readdir,cp} from 'node:fs/promises'
 import {join} from 'node:path'
 import {z} from 'zod'
 const uuid='[0-9a-f-]{36}'
@@ -6,10 +6,39 @@ export async function copyWorkspace(source:string,target:string){
   await cp(source,target,{recursive:true,dereference:false,verbatimSymlinks:true,filter:async path=>{const item=await lstat(path);return item.isFile()||item.isDirectory()||item.isSymbolicLink()}})
 }
 export async function replaceWorkspaceContents(source:string,target:string){
+  const [original,item]=await Promise.all([lstat(source),lstat(target)])
+  if(!original.isDirectory()||original.isSymbolicLink()||!item.isDirectory()||item.isSymbolicLink())throw new Error('恢复目录不安全')
+  const names=await readdir(source)
+  for(const name of await readdir(target))if(!names.includes(name))await rm(join(target,name),{recursive:true,force:true})
+  for(const name of names){
+    const from=join(source,name),to=join(target,name),entry=await lstat(from),existing=await lstat(to).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
+    // Keep common directories, including /home/cua/workspace, which is a
+    // nested mount point cached by Docker Desktop even between containers.
+    if(entry.isDirectory()&&!entry.isSymbolicLink()&&existing?.isDirectory()&&!existing.isSymbolicLink())await replaceWorkspaceContents(from,to)
+    else {if(existing)await rm(to,{recursive:true,force:true});await copyWorkspace(from,to)}
+  }
+  await chmod(target,original.mode&0o777)
+}
+/** These children are bind mounts too. macOS retains their inode associations
+ * even after the container is removed, so keep each mount root in place. */
+export async function replaceUserDataContents(source:string,target:string){
   const item=await lstat(target)
-  if(!item.isDirectory()||item.isSymbolicLink())throw new Error('恢复目录不安全')
-  for(const name of await readdir(target))await rm(join(target,name),{recursive:true,force:true})
-  await copyWorkspace(source,target)
+  if(!item.isDirectory()||item.isSymbolicLink())throw new Error('用户资料恢复目录不安全')
+  const names=await readdir(source)
+  for(const name of await readdir(target))if(!names.includes(name))await rm(join(target,name),{recursive:true,force:true})
+  for(const name of names){
+    const from=join(source,name),to=join(target,name),original=await lstat(from),existing=await lstat(to).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
+    if(['home','root'].includes(name)&&existing){
+      if(!original.isDirectory()||original.isSymbolicLink())throw new Error('用户资料备份目录不安全')
+      await replaceWorkspaceContents(from,to)
+    }else if(name==='machine-id'&&existing){
+      if(!original.isFile()||!existing.isFile()||original.isSymbolicLink()||existing.isSymbolicLink())throw new Error('设备标识备份不安全')
+      await chmod(to,0o600);await writeFile(to,await readFile(from));await chmod(to,0o444)
+    }else{
+      if(existing)await rm(to,{recursive:true,force:true})
+      await copyWorkspace(from,to)
+    }
+  }
 }
 /** Keep the bind-mount root inode stable on macOS shared filesystems. */
 export async function recoverWorkspace(base:string,id:string){
@@ -17,7 +46,8 @@ export async function recoverWorkspace(base:string,id:string){
   const journal=join(base,`.restore-${id}.json`)
   let value:unknown
   try{value=JSON.parse(await readFile(journal,'utf8'))}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return;throw error}
-  const record=z.object({protocol:z.literal(1),mode:z.literal('copy').optional(),staging:z.string().regex(new RegExp(`^\\.restore-${uuid}$`)),previous:z.string().regex(new RegExp(`^\\.before-restore-${id}-${uuid}$`))}).strict().parse(value)
+  const previousName=z.string().regex(new RegExp(`^\\.before-restore-${id}-${uuid}$`)),stagingName=z.string().regex(new RegExp(`^\\.restore-${uuid}$`))
+  const record=z.object({protocol:z.literal(1),mode:z.literal('copy').optional(),staging:stagingName,previous:previousName,userData:z.object({previous:previousName.nullable(),staging:stagingName}).optional()}).strict().parse(value)
   const workspace=join(base,id),previous=join(base,record.previous),staging=join(base,record.staging)
   const exists=async(path:string)=>{try{const stat=await lstat(path);if(!stat.isDirectory()||stat.isSymbolicLink())throw new Error('恢复目录不安全');return true}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return false;throw error}}
   if(record.mode==='copy'){
@@ -28,5 +58,16 @@ export async function recoverWorkspace(base:string,id:string){
     if(!await exists(previous))throw new Error('恢复日志中的原工作区缺失,需要手动核对')
     await rename(previous,workspace)
   }
+  if(record.userData){
+    const dataBase=join(base,'..','computer-userdata'),data=join(dataBase,id)
+    if(record.userData.previous===null)await rm(data,{recursive:true,force:true})
+    else{
+      const original=join(dataBase,record.userData.previous)
+      if(!await exists(original))throw new Error('恢复日志中的原用户资料缺失,需要手动核对')
+      if(await exists(data))await replaceUserDataContents(original,data)
+      else await copyWorkspace(original,data)
+    }
+    await rm(join(dataBase,record.userData.staging),{recursive:true,force:true})
+  }
   await rm(staging,{recursive:true,force:true});await rm(journal)
 }

+ 59 - 0
tests/fixtures/computer_auth.mjs

@@ -0,0 +1,59 @@
+// A private synthetic website and AES-encrypted application token. No accounts.
+import {createServer} from 'node:http'
+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 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})
+  const key=randomBytes(32),iv=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,iv)
+  const token=Buffer.concat([cipher.update('fixture-app-authorization'),cipher.final()])
+  await writeFile(keyring,key,{mode:0o600})
+  await writeFile(join(app,'token'),JSON.stringify({iv:iv.toString('hex'),tag:cipher.getAuthTag().toString('hex'),token:token.toString('hex')}))
+}
+const saved=JSON.parse(await readFile(join(app,'token'),'utf8')),decipher=createDecipheriv('aes-256-gcm',await readFile(keyring),Buffer.from(saved.iv,'hex'))
+decipher.setAuthTag(Buffer.from(saved.tag,'hex'))
+if(Buffer.concat([decipher.update(Buffer.from(saved.token,'hex')),decipher.final()]).toString()!=='fixture-app-authorization')throw Error('app authorization lost')
+const server=createServer((req,res)=>{
+  if(req.url==='/session'){res.end(JSON.stringify((req.headers.cookie??'').includes('session=fixture-cookie')));return}
+  if(login)res.setHeader('Set-Cookie','session=fixture-cookie; HttpOnly; SameSite=Lax; Max-Age=86400; Path=/')
+  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']})
+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=>{
+  buffer+=chunk.toString();let end
+  while((end=buffer.indexOf('\0'))>=0){
+    const message=JSON.parse(buffer.slice(0,end));buffer=buffer.slice(end+1)
+    if(message.id){const handler=waiting.get(message.id);waiting.delete(message.id);if(message.error)handler?.reject(Error(JSON.stringify(message.error)));else handler?.resolve(message.result)}
+  }
+})
+const closed=new Promise(resolve=>browser.once('close',resolve))
+const command=(method,params={},sessionId)=>new Promise((resolve,reject)=>{
+  const next=++id;waiting.set(next,{resolve,reject});browser.stdio[3].write(JSON.stringify({id:next,method,params,...(sessionId?{sessionId}:{})})+'\0')
+})
+const timeout=setTimeout(()=>{process.stderr.write('browser fixture timed out: '+errors);browser.kill();process.exit(1)},40000)
+try{
+  const target=await command('Target.createTarget',{url:'about:blank'})
+  const {sessionId}=await command('Target.attachToTarget',{targetId:target.targetId,flatten:true})
+  await command('Page.enable',{},sessionId)
+  await command('Page.navigate',{url:'http://127.0.0.1:18765/'},sessionId)
+  // CDP awaits the actual IndexedDB transaction, not a virtual-time delay.
+  const result=await command('Runtime.evaluate',{awaitPromise:true,returnByValue:true,expression:`(async()=>{
+    if(document.readyState!=='complete')await new Promise(resolve=>addEventListener('load',resolve,{once:true}));
+    const login=${JSON.stringify(login)};
+    if(login)localStorage.setItem('authorization','fixture-local');
+    const db=await new Promise((resolve,reject)=>{const request=indexedDB.open('fixture',1);request.onupgradeneeded=()=>request.result.createObjectStore('tokens');request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)});
+    if(login)await new Promise((resolve,reject)=>{const tx=db.transaction('tokens','readwrite');tx.objectStore('tokens').put('fixture-idb','auth');tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error)});
+    const token=await new Promise(resolve=>{const read=db.transaction('tokens').objectStore('tokens').get('auth');read.onsuccess=()=>resolve(read.result)});
+    const cookie=await (await fetch('/session')).json();db.close();
+    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
+  console.log(JSON.stringify({browser:true,encryptedApplicationToken:true}))
+}finally{clearTimeout(timeout);browser.kill();server.close()}

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

@@ -0,0 +1,48 @@
+// @vitest-environment node
+import {expect,it} from 'vitest'
+import {mkdtemp,readdir,readFile,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {ContainerComputerProvider,type ComputerSpecification} from '../../src/runner/computers/container'
+import {ComputerMaintenance} from '../../src/runner/computers/maintenance'
+
+it.skipIf(!process.env.YAOYAO_COMPUTER_IMAGE)('migrates a legacy home and keeps real browser and encrypted application authorization through rebuild and restore',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-auth-live-'))
+  const provider=new ContainerComputerProvider('docker',randomUUID(),home)
+  const spec:ComputerSpecification={id:randomUUID(),ownerKey:randomUUID(),imageId:process.env.YAOYAO_COMPUTER_IMAGE!}
+  const script=await readFile(new URL('../fixtures/computer_auth.mjs',import.meta.url),'utf8')
+  const execute=(argv:string[],user:'cua'|'root'='cua')=>provider.execute(spec,argv,{authorize:()=>{},user,timeout:60000}).catch(error=>{throw new Error(`${argv[0]} ${argv.at(-1)}: ${error.stderr??error.message}`)})
+  const maintenance=new ComputerMaintenance({status:()=>[{environmentId:spec.id,status:'free'}]} as any,provider,home)
+  let derivedImage:string|undefined
+  try{
+    // Exercise the real upgrade path from the old workspace-only container.
+    const workspace=await (provider as any).workspace(provider.validateSpecification(spec))
+    const created=await provider.run('docker',(provider as any).args(provider.validateSpecification(spec),workspace))
+    const legacyId=created.stdout.trim();await provider.run('docker',['start',legacyId])
+    expect(JSON.parse((await execute(['node','--input-type=module','-e',script,'login'])).stdout)).toEqual({browser:true,encryptedApplicationToken:true})
+    await execute(['sh','-c','mkdir -p /root/.config; printf root-fixture > /root/.config/authorization'],'root')
+    const identity=(await execute(['cat','/var/lib/dbus/machine-id'])).stdout
+    await provider.remove(spec)
+    const migrated=await provider.ensure(spec,()=>{})
+    expect(migrated.containerId).not.toBe(legacyId)
+    expect((await execute(['cat','/etc/machine-id'])).stdout).toBe(identity)
+    expect((await execute(['cat','/root/.config/authorization'],'root')).stdout).toBe('root-fixture')
+    expect(JSON.parse((await execute(['node','--input-type=module','-e',script,'check'])).stdout).browser).toBe(true)
+    await maintenance.rebuild(spec);await provider.ensure(spec,()=>{})
+    expect(JSON.parse((await execute(['node','--input-type=module','-e',script,'check'])).stdout).encryptedApplicationToken).toBe(true)
+    await provider.stop(spec)
+    derivedImage=(await provider.run('docker',['commit',(await provider.inspect(spec))!.containerId],{timeout:60000})).stdout.trim()
+    await provider.remove(spec);spec.imageId=derivedImage
+    await provider.ensure(spec,()=>{})
+    expect(JSON.parse((await execute(['node','--input-type=module','-e',script,'check'])).stdout).browser).toBe(true)
+    expect(await readdir(join(home,'computer-backups',spec.id))).toHaveLength(1)
+    await maintenance.backup(spec,join(home,'snapshot'))
+    await provider.ensure(spec,()=>{})
+    await execute(['sh','-c','rm -rf ~/.config/yaoyao-auth-fixture ~/.local/share/keyrings/yaoyao-auth-fixture ~/.config/yaoyao-auth-browser'])
+    await maintenance.restore(spec,join(home,'snapshot'))
+    await provider.ensure(spec,()=>{})
+    expect(JSON.parse((await execute(['node','--input-type=module','-e',script,'check'])).stdout).browser).toBe(true)
+    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)

+ 36 - 0
tests/server/computerMaintenance.test.ts

@@ -37,3 +37,39 @@ it('recovers an interrupted content restore without replacing the bind-mount roo
     await expect(lstat(staging)).rejects.toMatchObject({code:'ENOENT'})
   }finally{await rm(base,{recursive:true,force:true})}
 })
+it('backs up and restores private application data together with the workspace and checks tampering first',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-private-backup-')),id=randomUUID(),workspace=join(home,'computer-workspaces',id),data=join(home,'computer-userdata',id)
+  const spec={id,ownerKey:'fixture',imageId:'sha256:'+'a'.repeat(64)},snapshot=join(home,'snapshot')
+  const maintenance=new ComputerMaintenance({status:()=>[{environmentId:id,status:'free'}]} as any,{remove:async()=>{}} as any,home)
+  try{
+    await mkdir(workspace,{recursive:true});await mkdir(join(data,'home','.local','share','keyrings'),{recursive:true})
+    await mkdir(join(data,'home','workspace'));const nestedMountInode=(await lstat(join(data,'home','workspace'))).ino
+    await writeFile(join(workspace,'file'),'before');await writeFile(join(data,'home','.local','share','keyrings','login'),'encrypted-fixture')
+    await maintenance.backup(spec,snapshot)
+    await writeFile(join(workspace,'file'),'after');await writeFile(join(data,'home','.local','share','keyrings','login'),'new-fixture')
+    const result=await maintenance.restore(spec,snapshot)
+    expect(result.userDataRestored).toBe(true)
+    expect((await lstat(join(data,'home','workspace'))).ino).toBe(nestedMountInode)
+    expect(await readFile(join(data,'home','.local','share','keyrings','login'),'utf8')).toBe('encrypted-fixture')
+    expect(await readFile(join(result.previousUserData!,'home','.local','share','keyrings','login'),'utf8')).toBe('new-fixture')
+    await writeFile(join(snapshot,'userdata','home','.local','share','keyrings','login'),'tampered')
+    await writeFile(join(workspace,'file'),'must-remain')
+    await expect(maintenance.restore(spec,snapshot)).rejects.toThrow('校验失败')
+    expect(await readFile(join(workspace,'file'),'utf8')).toBe('must-remain')
+  }finally{await rm(home,{recursive:true,force:true})}
+})
+it('rolls both workspace and credentials back after an interrupted restore',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-private-restore-')),id=randomUUID(),base=join(home,'computer-workspaces'),dataBase=join(home,'computer-userdata')
+  const previousName=`.before-restore-${id}-${randomUUID()}`,stagingName=`.restore-${randomUUID()}`
+  try{
+    for(const parent of [base,dataBase]){
+      for(const name of [id,previousName,stagingName])await mkdir(join(parent,name),{recursive:true})
+      await writeFile(join(parent,id,'data'),'partially-restored')
+      await writeFile(join(parent,previousName,'data'),'original')
+    }
+    await writeFile(join(base,`.restore-${id}.json`),JSON.stringify({protocol:1,mode:'copy',previous:previousName,staging:stagingName,userData:{previous:previousName,staging:stagingName}}))
+    await recoverWorkspace(base,id)
+    for(const parent of [base,dataBase])expect(await readFile(join(parent,id,'data'),'utf8')).toBe('original')
+    await expect(lstat(join(base,`.restore-${id}.json`))).rejects.toMatchObject({code:'ENOENT'})
+  }finally{await rm(home,{recursive:true,force:true})}
+})

+ 69 - 5
tests/server/computerProvider.test.ts

@@ -29,7 +29,7 @@ async function fixture(runtime:'docker'|'podman'='docker',onCommand?:(args:strin
       const workspace=/source=([^,]+)/.exec(value('--mount'))![1]
       detail={Id:'b'.repeat(64),Image:spec.imageId,Config:{Labels:Object.fromEntries(values('--label').map(label=>label.split('=')))},State:{Running:false},
         HostConfig:{Memory:4096*1024*1024,MemorySwap:4096*1024*1024,NanoCpus:2e9,PidsLimit:512,Privileged:false,IpcMode:'private',CgroupnsMode:'private',NetworkMode:'none',RestartPolicy:{Name:'no'},CapAdd:values('--cap-add'),CapDrop:values('--cap-drop'),SecurityOpt:values('--security-opt'),PortBindings:{}},
-        Mounts:[{Type:'bind',Source:workspace,Destination:'/home/cua/workspace',RW:true}],NetworkSettings:{Ports:{}}}
+        Mounts:values('--mount').map(mount=>({Type:'bind',Source:/source=([^,]+)/.exec(mount)![1],Destination:/target=([^,]+)/.exec(mount)![1],RW:!mount.includes(',readonly')})),NetworkSettings:{Ports:{}}}
       return {stdout:detail.Id,stderr:''}
     }
     if(args[0]==='start')detail.State.Running=true
@@ -91,13 +91,13 @@ it('replaces an existing desktop created with the old no-new-privileges flag',as
   const state=await f.provider.ensure(f.spec,()=>{})
   expect(state.running).toBe(true)
   expect(f.detail.HostConfig.SecurityOpt).toEqual([])
-  expect(f.calls.filter(args=>args[0]==='create')).toHaveLength(2)
+  expect(f.calls.filter(args=>args[0]==='create')).toHaveLength(3)
   expect(f.calls.some(args=>args[0]==='rm')).toBe(true)
   expect(f.calls.flat().includes('no-new-privileges:true')).toBe(false)
 })
 it('does not stop or remove an identically named container belonging to another node',async()=>{
   const f=await fixture();await f.provider.ensure(f.spec,()=>{})
-  f.detail.Config.Labels['cn.samien.yaoyao.runner']=randomUUID()
+  f.calls.splice(0);f.detail.Config.Labels['cn.samien.yaoyao.runner']=randomUUID()
   await expect(f.provider.stop(f.spec)).rejects.toMatchObject({code:'computer_owner_mismatch'})
   await expect(f.provider.remove(f.spec)).rejects.toMatchObject({code:'computer_owner_mismatch'})
   expect(f.calls.some(args=>['stop','rm'].includes(args[0]!))).toBe(false)
@@ -125,7 +125,7 @@ it('recreates a migrated mount while retaining the stopped original container an
   const oldHome=join(oldRoot,'runner-state/r1'),newHome=join(newRoot,'runner-state/r1')
   await mkdir(oldHome,{recursive:true})
   const provider=new ContainerComputerProvider('docker',f.provider.runnerId,oldHome,f.run,{})
-  await provider.ensure(f.spec,()=>{});await provider.stop(f.spec)
+  await provider.ensure(f.spec,()=>{});await provider.stop(f.spec);f.calls.splice(0)
   await writeFile(join(oldHome,'computer-workspaces',f.spec.id,'keep.txt'),'keep workspace')
   await rename(oldRoot,newRoot)
   await writeFile(join(newRoot,'.data-home-migration.json'),JSON.stringify({source:oldRoot,target:newRoot,phase:'complete'}))
@@ -175,7 +175,7 @@ it('runs command lanes concurrently, serializes desktop input and drains every l
   await Promise.all([slowFile,slowGui,guiTwo,stopping])
   expect(guiTwoDone).toBe(true)
   expect(peak).toBeGreaterThanOrEqual(2)
-  expect(order.filter(entry=>entry.startsWith('exec:')).indexOf('exec:plain-gui-2')).toBeGreaterThan(order.indexOf('exec:hold-gui-1'))
+  expect(order.indexOf('exec:plain-gui-2')).toBeGreaterThan(order.indexOf('exec:hold-gui-1'))
   expect(order.indexOf('stop')).toBeGreaterThan(order.indexOf('exec:plain-gui-2'))
 })
 it('keeps a shared desktop alive when one holder cancels an unacknowledged operation',async()=>{
@@ -220,3 +220,67 @@ it('requires a guest stop acknowledgement for a cancelled command and preserves
   expect(await execution).toMatchObject({code:'computer_cancelled'})
   expect(calls.some(args=>args[0]==='stop')).toBe(false)
 })
+it('retains a stopped legacy container when copying private data fails, then migrates on retry',async()=>{
+  const f=await fixture();await f.provider.ensure(f.spec,()=>{})
+  f.detail.Mounts=f.detail.Mounts.filter((m:any)=>m.Destination==='/home/cua/workspace')
+  delete f.detail.Config.Labels['cn.samien.yaoyao.userdata']
+  await rm(join(f.home,'computer-userdata',f.spec.id),{recursive:true});await rm(join(f.home,'computer-userdata',f.spec.id)+'.required');f.calls.splice(0)
+  const broken=new ContainerComputerProvider('docker',f.provider.runnerId,f.home,async(runtime,args,options)=>{
+    if(args[0]==='cp')throw Object.assign(new Error('disk full'),{stderr:'private file detail'})
+    return f.run(runtime,args,options)
+  },{})
+  await expect(broken.remove(f.spec)).rejects.toThrow('原容器已保留')
+  expect(f.detail.State.Running).toBe(false)
+  expect(f.calls.some(args=>args[0]==='rm')).toBe(false)
+  const retry=new ContainerComputerProvider('docker',f.provider.runnerId,f.home,async(runtime,args,options)=>{
+    if(args[0]==='cp'&&args[1]?.endsWith('machine-id')){await writeFile(args[2]!, 'f'.repeat(32)+'\n');return {stdout:'',stderr:''}}
+    return f.run(runtime,args,options)
+  },{})
+  await retry.ensure(f.spec,()=>{})
+  expect(f.detail.State.Running).toBe(true)
+  expect(f.detail.Mounts.some((m:any)=>m.Destination==='/home/cua')).toBe(true)
+  expect(await (await import('node:fs/promises')).readFile(join(f.home,'computer-userdata',f.spec.id,'machine-id'),'utf8')).toBe('f'.repeat(32)+'\n')
+})
+it('keeps user data on remove and deletes it only with the explicit data deletion',async()=>{
+  const f=await fixture();await f.provider.ensure(f.spec,()=>{})
+  const data=join(f.home,'computer-userdata',f.spec.id)
+  await writeFile(join(data,'home','authorization'),'fixture')
+  await f.provider.remove(f.spec)
+  expect(await (await import('node:fs/promises')).readFile(join(data,'home','authorization'),'utf8')).toBe('fixture')
+  await f.provider.ensure(f.spec,()=>{})
+  await expect(f.provider.deleteWorkspace(f.spec)).rejects.toMatchObject({code:'computer_busy'})
+  await f.provider.remove(f.spec);await f.provider.deleteWorkspace(f.spec)
+  await expect((await import('node:fs/promises')).lstat(data)).rejects.toMatchObject({code:'ENOENT'})
+})
+it('refuses replacing missing, linked or differently owned user profiles with an empty profile',async()=>{
+  const f=await fixture();await f.provider.ensure(f.spec,()=>{});await f.provider.remove(f.spec)
+  const data=join(f.home,'computer-userdata',f.spec.id)
+  await writeFile(join(data,'identity.json'),JSON.stringify({protocol:1,environmentId:f.spec.id,owner:'different'}))
+  f.calls.splice(0)
+  await expect(f.provider.ensure(f.spec,()=>{})).rejects.toThrow('归属不匹配')
+  expect(f.calls.some(args=>args[0]==='create')).toBe(false)
+  await rm(data,{recursive:true});await symlink(f.home,data)
+  await expect(f.provider.ensure(f.spec,()=>{})).rejects.toThrow('目录不安全')
+})
+it('backs up the old image data before a different image can open application databases',async()=>{
+  const f=await fixture();await f.provider.ensure(f.spec,()=>{})
+  const fs=await import('node:fs/promises'),data=join(f.home,'computer-userdata',f.spec.id),oldImage=f.spec.imageId
+  await writeFile(join(data,'home','authorization'),'old-image-credential')
+  await f.provider.remove(f.spec);f.spec.imageId='sha256:'+'c'.repeat(64)
+  await f.provider.ensure(f.spec,()=>{})
+  const backups=join(f.home,'computer-backups',f.spec.id),names=await fs.readdir(backups)
+  expect(names).toHaveLength(1)
+  const snapshot=join(backups,names[0]!)
+  expect(JSON.parse(await fs.readFile(join(snapshot,'snapshot.json'),'utf8')).imageId).toBe(oldImage)
+  expect(await fs.readFile(join(snapshot,'userdata','home','authorization'),'utf8')).toBe('old-image-credential')
+  await writeFile(join(data,'home','authorization'),'upgraded')
+  await f.provider.remove(f.spec);await f.provider.ensure(f.spec,()=>{})
+  expect(await fs.readdir(backups)).toEqual(names)
+  expect(await fs.readFile(join(snapshot,'userdata','home','authorization'),'utf8')).toBe('old-image-credential')
+})
+it('refuses to silently reseed a previously persisted environment after its data disappears',async()=>{
+  const f=await fixture();await f.provider.ensure(f.spec,()=>{});await f.provider.remove(f.spec)
+  await rm(join(f.home,'computer-userdata',f.spec.id),{recursive:true});f.calls.splice(0)
+  await expect(f.provider.ensure(f.spec,()=>{})).rejects.toThrow('用户资料缺失')
+  expect(f.calls.some(args=>args[0]==='create')).toBe(false)
+})

+ 15 - 0
tests/server/desktopSync.test.ts

@@ -188,6 +188,21 @@ describe('transactional service switching', () => {
     expect(f.counts()).toEqual({ stops: 0, starts: 0 })
     expect(existsSync(join(f.home, 'updates', 'desktop-sync.json'))).toBe(false)
   })
+  it('preserves VM data after quiescing and before the old service can remove its containers',async()=>{
+    const f=fixture(),order:string[]=[]
+    f.driver.quiesce=async()=>{order.push('quiesce')}
+    ;(f.driver as any).preserveComputerData=async(root:string)=>{expect(root).toBe(f.target);order.push('preserve')}
+    const stop=f.driver.stop;f.driver.stop=async()=>{order.push('stop');await stop()}
+    await transitionService({...f,finalRoot:f.target})
+    expect(order).toEqual(['quiesce','preserve','stop'])
+  })
+  it('keeps the old service and container when VM migration fails',async()=>{
+    const f=fixture()
+    ;(f.driver as any).preserveComputerData=async()=>{throw new Error('VM copy failed')}
+    await expect(transitionService({...f,finalRoot:f.target})).rejects.toThrow('VM copy failed')
+    expect(f.counts()).toEqual({stops:0,starts:0})
+    expect(currentRelease(f.releaseRoot)).toBe(f.old)
+  })
   it('explicitly overwrites an unrelated same-version build, preserving data and bypassing an old sync receipt', async () => {
     const f=fixture()
     runtime(f.old,'c'.repeat(40),'0.3.32',[])

+ 34 - 0
tests/server/vmDataMigration.test.ts

@@ -0,0 +1,34 @@
+// @vitest-environment node
+import {it,expect,vi} from 'vitest'
+import {mkdtemp,mkdir,writeFile,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {createCipheriv,randomBytes,randomUUID} from 'node:crypto'
+import {DatabaseSync} from 'node:sqlite'
+import {ContainerComputerProvider} from '../../src/runner/computers/container'
+import {preserveManagedVmData} from '../../src/runner/computers/migrateUserData'
+it('preserves the quiesced managed runner before old service shutdown, without changing its databases',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-migration-')),runnerId=randomUUID(),key=randomBytes(32),nonce=randomBytes(12)
+  const cipher=createCipheriv('aes-256-gcm',key,nonce),payload=Buffer.concat([cipher.update(JSON.stringify({runnerId,token:'private-fixture',computers:{runtime:'docker'}})),cipher.final()])
+  const sealed=Buffer.concat([nonce,cipher.getAuthTag(),payload]).toString('base64')
+  const workspace=new DatabaseSync(join(home,'workspace.sqlite3'))
+  workspace.exec('CREATE TABLE workspace_entities(owner TEXT,kind TEXT,id TEXT,data TEXT)')
+  workspace.prepare('INSERT INTO workspace_entities VALUES(?,?,?,?)').run('_system','local-vm-managed','local',JSON.stringify({sealed}));workspace.close()
+  await writeFile(join(home,'workspace-key.bin'),key,{mode:0o600})
+  const directory=join(home,'runner-state','local-vm',runnerId);await mkdir(directory,{recursive:true})
+  const db=new DatabaseSync(join(directory,'runner-commands.sqlite3')),spec={id:randomUUID(),ownerKey:'fixture',imageId:'sha256:'+'a'.repeat(64)}
+  db.exec('CREATE TABLE computer_environments(id TEXT,value TEXT)')
+  const entry=JSON.stringify({spec,status:'idle'});db.prepare('INSERT INTO computer_environments VALUES(?,?)').run(spec.id,entry)
+  const spy=vi.spyOn(ContainerComputerProvider.prototype,'preserveUserData').mockResolvedValue(true)
+  try{
+    expect(await preserveManagedVmData(home)).toEqual({preserved:1})
+    expect(spy).toHaveBeenCalledWith(spec)
+    expect(db.prepare('SELECT value FROM computer_environments').get()?.value).toBe(entry)
+    spy.mockClear();db.prepare('UPDATE computer_environments SET value=?').run(JSON.stringify({spec,status:'active'}))
+    await expect(preserveManagedVmData(home)).rejects.toThrow('仍在使用')
+    expect(spy).not.toHaveBeenCalled()
+    spy.mockRejectedValue(new Error('disk full'));db.prepare('UPDATE computer_environments SET value=?').run(entry)
+    await expect(preserveManagedVmData(home)).rejects.toThrow('disk full')
+    expect(db.prepare('SELECT value FROM computer_environments').get()?.value).toBe(entry)
+  }finally{spy.mockRestore();db.close();await rm(home,{recursive:true,force:true})}
+})