Browse Source

提供电脑镜像交付与可恢复工作区维护

LSamien 1 month ago
parent
commit
66d2ecf2b2

+ 96 - 0
deploy/computer/Dockerfile

@@ -0,0 +1,96 @@
+# Derived from OpenMausBot server/container-computer.ts (Apache-2.0).
+# See third-party/openmausbot. Local changes: Yaoyao image metadata.
+FROM docker.io/trycua/xfce-cua@sha256:274eb636f5cf3fc58f705916ee72b7a701270b3877369d08533a385c5325be9b
+USER root
+RUN set -eux; \
+    arch="$(uname -m)"; \
+    case "$arch" in \
+      x86_64) wheel_url='https://files.pythonhosted.org/packages/fa/d7/a43008a328a40c85e7bc706fc20235b9abedc75e28b413817655153157ff/cua_driver-0.20.0-py3-none-manylinux_2_31_x86_64.whl'; wheel_sha='f60c35696a37f37ac954935e478ae4754f220856d022036625c9400d72185961'; wheel_path='/tmp/cua_driver-0.20.0-py3-none-manylinux_2_31_x86_64.whl'; lib_triplet='x86_64-linux-gnu' ;; \
+      aarch64|arm64) wheel_url='https://files.pythonhosted.org/packages/94/9d/1c1838b69067e83266c3d2aae02d74eef353a43dc8644884ccf03fe7f933/cua_driver-0.20.0-py3-none-manylinux_2_31_aarch64.whl'; wheel_sha='48833bc5e4c60e701fc9eefb57dbac36ec77ef3990f816fbbe85b4e954af2c77'; wheel_path='/tmp/cua_driver-0.20.0-py3-none-manylinux_2_31_aarch64.whl'; lib_triplet='aarch64-linux-gnu' ;; \
+      *) echo "unsupported architecture: $arch" >&2; exit 1 ;; \
+    esac; \
+    for ssl_lib in "/lib/$lib_triplet/libssl.so.3" "/lib/$lib_triplet/libcrypto.so.3"; do \
+      if [ -e "$ssl_lib" ] && [ ! -s "$ssl_lib" ]; then \
+        echo "pinned base image is defective on $arch: $ssl_lib is zero bytes, so curl cannot start — re-pull or replace the base image instead of debugging the wheel download" >&2; \
+        exit 1; \
+      fi; \
+    done; \
+    curl -fsSL "$wheel_url" -o "$wheel_path"; \
+    echo "$wheel_sha  $wheel_path" | sha256sum -c -; \
+    /opt/venv/bin/python -m pip install --no-cache-dir --force-reinstall --no-deps "$wheel_path"; \
+    rm -f "$wheel_path"; \
+    driver_bin="$(find /opt/venv/lib -path '*/cua_driver/bin/cua-driver' -type f -print -quit)"; \
+    test -n "$driver_bin"; \
+    install -D -m 0755 "$driver_bin" /usr/local/libexec/openmausbot/cua-driver; \
+    install -d -o cua -g cua -m 0700 /home/cua/workspace; \
+    test "$(/usr/local/libexec/openmausbot/cua-driver --version)" = "cua-driver 0.20.0"
+# Install before XFCE starts so the panel and window manager see the font too.
+# Noto Sans CJK JP is distributed under the SIL Open Font License 1.1.
+RUN set -eux; \
+    install -d -m 0755 /usr/local/share/fonts; \
+    curl -fsSL 'https://raw.githubusercontent.com/notofonts/noto-cjk/165c01b46ea533872e002e0785ff17e44f6d97d8/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf' -o /usr/local/share/fonts/NotoSansCJKjp-Regular.otf; \
+    echo '68a3fc98800b2a27b371f2fb79991daf3633bd89309d4ffaa6946fd587f375b5  /usr/local/share/fonts/NotoSansCJKjp-Regular.otf' | sha256sum -c -; \
+    chmod 0644 /usr/local/share/fonts/NotoSansCJKjp-Regular.otf; \
+    install -d -m 0755 /usr/local/share/licenses/noto-cjk; \
+    curl -fsSL 'https://raw.githubusercontent.com/notofonts/noto-cjk/165c01b46ea533872e002e0785ff17e44f6d97d8/LICENSE' -o /usr/local/share/licenses/noto-cjk/OFL.txt; \
+    echo '6a73f9541c2de74158c0e7cf6b0a58ef774f5a780bf191f2d7ec9cc53efe2bf2  /usr/local/share/licenses/noto-cjk/OFL.txt' | sha256sum -c -; \
+    fc-cache -f
+RUN printf '%s\n' \
+      '#!/bin/sh' \
+      'set -eu' \
+      'workspace=/home/cua/workspace' \
+      'profiles="$workspace/.browser-profiles"' \
+      'mkdir -p "$profiles/google-chrome" "$profiles/chromium" "$HOME/.config"' \
+      'if ! chmod 0700 "$workspace" "$profiles" "$profiles/google-chrome" "$profiles/chromium" 2>/dev/null; then' \
+      '  for directory in "$workspace" "$profiles" "$profiles/google-chrome" "$profiles/chromium"; do' \
+      '    test -r "$directory" && test -w "$directory" && test -x "$directory"' \
+      '  done' \
+      'fi' \
+      'migrate_profile() {' \
+      '  name="$1"' \
+      '  source="$HOME/.config/$name"' \
+      '  target="$profiles/$name"' \
+      '  if [ -d "$source" ] && [ ! -L "$source" ] && [ -z "$(find "$target" -mindepth 1 -print -quit)" ]; then' \
+      '    cp -a "$source"/. "$target"/' \
+      '  fi' \
+      '  rm -rf "$source"' \
+      '  ln -s "$target" "$source"' \
+      '}' \
+      'migrate_profile google-chrome' \
+      'migrate_profile chromium' \
+      'find "$profiles" \( -name SingletonLock -o -name SingletonSocket -o -name SingletonCookie -o -name .parentlock \) -delete' \
+      > /usr/local/bin/prepare-openmausbot-workspace.sh \
+    && chmod 0755 /usr/local/bin/prepare-openmausbot-workspace.sh
+RUN printf '%s\n' \
+      '#!/bin/sh' \
+      '/usr/local/bin/prepare-openmausbot-workspace.sh' \
+      'attempt=0' \
+      'until DISPLAY=:1 xset q >/dev/null 2>&1; do' \
+      '  attempt=$((attempt + 1))' \
+      '  if [ "$attempt" -ge 45 ]; then echo "X display :1 did not become ready within 45 seconds" >&2; exit 1; fi' \
+      '  sleep 1' \
+      'done' \
+      'exec env CUA_DRIVER_INSTALL_CHANNEL=python_package CUA_DRIVER_RS_TELEMETRY_ENABLED=0 /usr/local/libexec/openmausbot/cua-driver serve --socket /run/user/1000/openmausbot-cua.sock --permission-mode standard' \
+      > /usr/local/bin/start-openmausbot-cua-driver.sh \
+    && chmod 0755 /usr/local/bin/start-openmausbot-cua-driver.sh
+RUN printf '%s\n' \
+      '' \
+      '[program:openmausbot-cua-driver]' \
+      'command=/usr/local/bin/start-openmausbot-cua-driver.sh' \
+      'user=cua' \
+      'environment=HOME="/home/cua",USER="cua",DISPLAY=":1"' \
+      'autorestart=true' \
+      'startsecs=2' \
+      'stdout_logfile=/var/log/supervisor/cua-driver.log' \
+      'stderr_logfile=/var/log/supervisor/cua-driver.error.log' \
+      'priority=30' \
+      >> /etc/supervisor/supervisord.conf
+LABEL com.openmausbot.local-vm="1" \
+      com.openmausbot.cua-driver="0.20.0" \
+      com.openmausbot.cua-base="sha256:274eb636f5cf3fc58f705916ee72b7a701270b3877369d08533a385c5325be9b" \
+      com.openmausbot.image-layer="5"
+
+LABEL cn.samien.yaoyao.computer-image="1"
+COPY prepare-firefox.sh /usr/local/bin/prepare-yaoyao-firefox.sh
+RUN chmod 0755 /usr/local/bin/prepare-yaoyao-firefox.sh \
+    && sed -i '2i /usr/local/bin/prepare-yaoyao-firefox.sh || exit 1' /usr/local/bin/start-openmausbot-cua-driver.sh

+ 11 - 0
deploy/computer/prepare-firefox.sh

@@ -0,0 +1,11 @@
+#!/bin/sh
+set -eu
+profiles=/home/cua/workspace/.browser-profiles/firefox
+mkdir -p "$profiles"
+chmod 0700 "$profiles" 2>/dev/null || test -w "$profiles"
+if [ -d "$HOME/.mozilla" ] && [ ! -L "$HOME/.mozilla" ] && [ -z "$(ls -A "$profiles")" ]; then
+  cp -a "$HOME/.mozilla/." "$profiles/"
+fi
+rm -rf "$HOME/.mozilla"
+ln -s "$profiles" "$HOME/.mozilla"
+find "$profiles" \( -name .parentlock -o -name parent.lock -o -name lock \) -delete

+ 3 - 0
docs/agent-platform-implementation.md

@@ -86,3 +86,6 @@
 
 
 - 电脑控制阶段:Web、macOS、iOS、Android 入口已接入只读画面与独立人工控制凭据;暂停模型、等待在途工具、同一电脑递增代次转交、交还后继续原任务。真实 Docker 终端点击/输入/文件证明和原任务续跑通过。详见 `docs/computer-control.md`。跨端原生视觉、镜像管理、共享环境和最终桌面交付仍待完成。
 - 电脑控制阶段:Web、macOS、iOS、Android 入口已接入只读画面与独立人工控制凭据;暂停模型、等待在途工具、同一电脑递增代次转交、交还后继续原任务。真实 Docker 终端点击/输入/文件证明和原任务续跑通过。详见 `docs/computer-control.md`。跨端原生视觉、镜像管理、共享环境和最终桌面交付仍待完成。
 - 电脑控制源码检查:类型检查和 Web 构建通过,762 项全量测试通过,5 项浏览器流程通过,iOS 28 项 Workspace 测试通过,Android 模型测试和 Kotlin 编译通过。窄屏电脑面板截图已核对。本轮验收容器全部清理,正式 Web 服务 PID 28292 未改变。
 - 电脑控制源码检查:类型检查和 Web 构建通过,762 项全量测试通过,5 项浏览器流程通过,iOS 28 项 Workspace 测试通过,Android 模型测试和 Kotlin 编译通过。窄屏电脑面板截图已核对。本轮验收容器全部清理,正式 Web 服务 PID 28292 未改变。
+
+- 镜像与维护阶段:本仓库提供固定来源的镜像配方、健康/截图验证、校验归档导入导出、停止后的工作区备份/恢复/重建及内容恢复中断后的回退。新镜像不会自动替换运行环境;保留旧镜像与恢复前数据。真实 arm64 Docker 构建、归档往返通过。详见 `docs/computer-maintenance.md`。
+- 维护阶段最终验证:类型检查和 767 项全量测试通过;真实新镜像的工作区恢复后重启读写通过;打包 Runner / 新镜像的完整 Worker 验收 2 项通过,包含公网、临时助手和人工接管。恢复保留挂载根目录以兼容 macOS 文件共享,并测试中断回退。

+ 47 - 0
docs/computer-maintenance.md

@@ -0,0 +1,47 @@
+# 电脑镜像与工作区维护
+
+镜像配方和维护工具由本仓库交付,不需要访问 OpenMausBot 的本地目录。`deploy/computer/Dockerfile` 从固定摘要的 CUA XFCE 基础镜像构建,驱动、字体和许可证下载均校验 SHA-256;保留兼容的 CUA 路径与标签,增加夭夭镜像标签及 Firefox 持久资料。派生配方的 Apache-2.0 许可和来源在 `third-party/openmausbot`。
+
+## 准备与离线搬运
+
+先运行 `npm run runner:build`。以下命令也在 macOS App 的 `Contents/Resources/runtime` 内提供,可用 Node.js 24 执行:
+
+```sh
+node .runner-build/runner-image.mjs prepare --runtime docker --output /安全目录/computer-image.json
+node .runner-build/runner-image.mjs inspect --image sha256:完整镜像ID
+node .runner-build/runner-image.mjs export --image sha256:完整镜像ID --archive /安全目录/computer.tar
+node .runner-build/runner-image.mjs import --archive /安全目录/computer.tar
+```
+
+`--runtime podman` 使用相同流程。Docker/Podman 必须指向本机运行时;远程主机在当地运行 Runner 和准备工具。缺少运行时或驱动未就绪会报错,不创建宿主执行替代路径。
+
+准备与导入都会创建一次独立验收电脑,等待驱动、读取健康状态和真实截图,随后清理验收容器。导出的 `.tar.json` 记录完整镜像 ID、架构、协议、驱动和归档 SHA-256;导入先验证归档,再核对实际镜像身份及架构。导出不覆盖现有归档。工具不会自动修改 Runner 配置,也不会替换运行中的电脑。
+
+更新时先准备并验证新镜像,停止 Runner 后把配置的 `computers.imageId` 改成新的完整 ID,再启动。资源池只在环境空闲时应用新配置,保留工作区;旧镜像不自动删除。回退使用原镜像 ID,必要时恢复升级前的工作区备份。浏览器资料格式也可能升级,因此不能以镜像回退代替数据回退。
+
+## 工作区备份、恢复与重建
+
+必须先停止该 Runner。维护工具获取与 Runner 相同的数据目录独占锁,不能和正在执行的 Agent 或人工接管并行运行。它首先处理崩溃遗留的租约和容器;未确认停止的环境不能维护。
+
+```sh
+node .runner-build/runner-maintenance.mjs list --config /安全目录/runner.json
+node .runner-build/runner-maintenance.mjs backup --config /安全目录/runner.json --environment UUID --snapshot /备份目录/本次备份
+node .runner-build/runner-maintenance.mjs restore --config /安全目录/runner.json --environment UUID --snapshot /备份目录/本次备份
+node .runner-build/runner-maintenance.mjs rebuild --config /安全目录/runner.json --environment UUID
+```
+
+CLI 默认数据目录为配置旁的 `state/<runnerId>`。App 托管节点的数据目录是 `~/.hermes-yaoyao/runner-state/<runnerId>`,维护时用 `--home` 显式指定该目录,并使用原始私有配置文件;App 加密配置不能直接当作 JSON 读取。
+
+备份包含工作文件及持久浏览器资料,不包含运行中的内存、进程、模型密钥、Runner 身份或整个宿主 HOME。目录设为仅当前用户访问,浏览器资料仍可能包含登录状态,应作为私有数据保存。每份备份有账号/环境归属和逐文件校验;普通文件、目录、符号链接保留,不复制运行时 socket。上限为 20 GiB、20 万项。
+
+恢复先验证完整内容并复制到暂存目录;恢复前的数据经校验保留为 `.before-restore-*`,不自动删除。保留挂载根目录并替换其内容,以兼容 macOS 容器共享文件系统。恢复前写入日志,进程若在恢复中退出,下一次启动先从旧数据副本回退,不使用部分恢复的工作区。重建只退休已停止的容器身份,下次启动从配置镜像重新创建,保留工作区;它不是清空数据。
+
+这是停止电脑后的文件一致备份。浏览器或数据库在强制中断前未落盘的内容不在备份中,不能宣称应用事务快照。
+
+## 验证状态
+
+2026-09-09 在 arm64 Docker 上从本仓库构建、健康/截图检查、归档导出、SHA-256 校验后重新导入通过。验证镜像:`sha256:befa37db3c64fa231237d6898d9403029ca9f02a01beb41ff1cf0de113130f55`。归档:`/tmp/yaoyao-computer-0909.tar`,SHA-256 为 `63a72afff1169439d74b500b883d8cff4bbbf726148c21a64226d8d3b02f2f7e`。
+
+离线测试覆盖驱动/身份/架构不符、损坏归档不调用运行时、拒绝覆盖、在用环境拒绝维护、恢复前校验、保留旧数据及内容恢复中途崩溃回退。Podman 参数一致性有离线证据,真实 Podman 与 amd64 构建尚未验收。
+
+真实电脑备份/恢复后重新启动并读取原文件通过,恢复前文件保留在旧数据副本。新镜像的打包 Runner / Hermes Worker 验收 2 项通过,包含公网访问、临时助手、产物回传、人工接管和原任务续跑。类型检查及 767 项全量测试通过。

+ 2 - 2
docs/computer-provider.md

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

+ 4 - 0
electron-builder.yml

@@ -13,6 +13,10 @@ extraResources:
       - server.mjs.map
       - server.mjs.map
       - runner.mjs
       - runner.mjs
       - runner.mjs.map
       - runner.mjs.map
+      - runner-image.mjs
+      - runner-maintenance.mjs
+      - computer-image/**/*
+      - third-party/**/*
       - runner-config.mjs
       - runner-config.mjs
       - runner-config.mjs.map
       - runner-config.mjs.map
       - hermes_worker.py
       - hermes_worker.py

+ 4 - 2
scripts/build-runner.mjs

@@ -1,5 +1,5 @@
 import { build } from 'esbuild'
 import { build } from 'esbuild'
-import { mkdir,copyFile } from 'node:fs/promises'
+import { mkdir,copyFile,cp } from 'node:fs/promises'
 import { resolve } from 'node:path'
 import { resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { fileURLToPath } from 'node:url'
 
 
@@ -7,8 +7,10 @@ export async function buildRunner(directory = resolve(import.meta.dirname, '../.
   await mkdir(directory, { recursive: true })
   await mkdir(directory, { recursive: true })
   await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))
   await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))
   await copyFile(resolve(import.meta.dirname,'../src/runner/network/guest_proxy.py'),resolve(directory,'guest_proxy.py'))
   await copyFile(resolve(import.meta.dirname,'../src/runner/network/guest_proxy.py'),resolve(directory,'guest_proxy.py'))
+  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({
   await build({
-    entryPoints: { runner:resolve(import.meta.dirname, '../src/runner/index.ts'), 'runner-config':resolve(import.meta.dirname, '../src/runner/config.ts') },
+    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') },
     outdir:directory, outExtension:{'.js':'.mjs'}, bundle: true, platform: 'node',
     outdir:directory, outExtension:{'.js':'.mjs'}, bundle: true, platform: 'node',
     format: 'esm', target: 'node24', sourcemap: true,
     format: 'esm', target: 'node24', sourcemap: true,
     banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" },
     banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" },

+ 3 - 1
scripts/copy-worker.mjs

@@ -1,7 +1,9 @@
-import {mkdir,copyFile} from 'node:fs/promises'
+import {mkdir,copyFile,cp} from 'node:fs/promises'
 import {resolve} from 'node:path'
 import {resolve} from 'node:path'
 const directory=resolve(import.meta.dirname,'../dist-server/runner/worker')
 const directory=resolve(import.meta.dirname,'../dist-server/runner/worker')
 await mkdir(directory,{recursive:true})
 await mkdir(directory,{recursive:true})
 await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))
 await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))
 
 
 await copyFile(resolve(import.meta.dirname,'../src/runner/network/guest_proxy.py'),resolve(directory,'guest_proxy.py'))
 await copyFile(resolve(import.meta.dirname,'../src/runner/network/guest_proxy.py'),resolve(directory,'guest_proxy.py'))
+
+await cp(resolve(import.meta.dirname,'../deploy/computer'),resolve(directory,'../computers/computer-image'),{recursive:true})

+ 3 - 1
src/runner/computers/container.ts

@@ -3,6 +3,7 @@ import { spawn } from 'node:child_process'
 import { mkdir, realpath, chmod, lstat, rm } from 'node:fs/promises'
 import { mkdir, realpath, chmod, lstat, rm } from 'node:fs/promises'
 import { join, resolve, posix } from 'node:path'
 import { join, resolve, posix } from 'node:path'
 import { z } from 'zod'
 import { z } from 'zod'
+import {recoverWorkspace} from './workspaceRecovery.js'
 
 
 export const COMPUTER_WORKSPACE = '/home/cua/workspace'
 export const COMPUTER_WORKSPACE = '/home/cua/workspace'
 export const CUA_DRIVER = '/usr/local/libexec/openmausbot/cua-driver'
 export const CUA_DRIVER = '/usr/local/libexec/openmausbot/cua-driver'
@@ -55,7 +56,7 @@ export class ContainerComputerProvider implements ComputerProvider {
     z.string().uuid().parse(runnerId)
     z.string().uuid().parse(runnerId)
     if(!['docker','podman'].includes(runtime))throw new ComputerError('runtime_unsupported','电脑运行时不受支持')
     if(!['docker','podman'].includes(runtime))throw new ComputerError('runtime_unsupported','电脑运行时不受支持')
   }
   }
-  private async verifyRuntime() {
+  async verifyRuntime() {
     if(this.runtime==='docker') {
     if(this.runtime==='docker') {
       const endpoint=this.environment.DOCKER_HOST||JSON.parse((await this.run('docker',['context','inspect','--format','{{json .Endpoints.docker.Host}}'])).stdout)
       const endpoint=this.environment.DOCKER_HOST||JSON.parse((await this.run('docker',['context','inspect','--format','{{json .Endpoints.docker.Host}}'])).stdout)
       if(typeof endpoint!=='string'||!(/^(?:unix|npipe):\/\//.test(endpoint)))throw new ComputerError('computer_remote_engine','远程 Docker 引擎请在目的电脑安装 Runner')
       if(typeof endpoint!=='string'||!(/^(?:unix|npipe):\/\//.test(endpoint)))throw new ComputerError('computer_remote_engine','远程 Docker 引擎请在目的电脑安装 Runner')
@@ -82,6 +83,7 @@ export class ContainerComputerProvider implements ComputerProvider {
     const base=resolve(this.home,'computer-workspaces')
     const base=resolve(this.home,'computer-workspaces')
     await mkdir(base,{recursive:true,mode:0o700})
     await mkdir(base,{recursive:true,mode:0o700})
     const canonical=await realpath(base),path=join(canonical,spec.id)
     const canonical=await realpath(base),path=join(canonical,spec.id)
+    await recoverWorkspace(canonical,spec.id)
     if(/[,\n\r\0]/.test(path))throw new ComputerError('computer_path_invalid','电脑数据目录包含不支持的字符')
     if(/[,\n\r\0]/.test(path))throw new ComputerError('computer_path_invalid','电脑数据目录包含不支持的字符')
     await mkdir(path,{recursive:true,mode:0o700})
     await mkdir(path,{recursive:true,mode:0o700})
     if(await realpath(path)!==path)throw new ComputerError('computer_path_unsafe','电脑工作区不能指向其他目录')
     if(await realpath(path)!==path)throw new ComputerError('computer_path_unsafe','电脑工作区不能指向其他目录')

+ 22 - 0
src/runner/computers/imageCli.ts

@@ -0,0 +1,22 @@
+import {parseArgs} from 'node:util'
+import {fileURLToPath} from 'node:url'
+import {ComputerImages,writeManifest} from './images.js'
+import {ContainerComputerProvider} from './container.js'
+import {randomUUID} from 'node:crypto'
+import {tmpdir} from 'node:os'
+try{
+  const {values,positionals}=parseArgs({allowPositionals:true,options:{runtime:{type:'string',default:'docker'},image:{type:'string'},archive:{type:'string'},output:{type:'string'},recipe:{type:'string'}}})
+  if(!['docker','podman'].includes(values.runtime!))throw new Error('运行时必须为 docker 或 podman')
+  const runtime=values.runtime as 'docker'|'podman',images=new ComputerImages(runtime)
+  await new ContainerComputerProvider(runtime,randomUUID(),tmpdir()).verifyRuntime()
+  let result
+  switch(positionals[0]){
+    case 'prepare': result=await images.prepare(values.recipe??fileURLToPath(new URL('./computer-image',import.meta.url)));break
+    case 'inspect':if(!values.image)throw new Error('需要 --image sha256:完整镜像ID');result=await images.inspect(values.image);break
+    case 'export':if(!values.image||!values.archive)throw new Error('需要 --image 和 --archive');result=await images.export(values.image,values.archive);break
+    case 'import':if(!values.archive)throw new Error('需要 --archive');result=await images.import(values.archive);break
+    default:throw new Error('用法:runner-image prepare|inspect|export|import [--runtime docker|podman] [--image ID] [--archive 文件] [--output 清单.json]')
+  }
+  if(values.output)await writeManifest(values.output,result)
+  process.stdout.write(JSON.stringify(result,null,2)+'\n')
+}catch(error){process.stderr.write(`${error instanceof Error?error.message:'镜像操作失败'}\n`);process.exitCode=1}

+ 74 - 0
src/runner/computers/images.ts

@@ -0,0 +1,74 @@
+import {createHash,randomUUID} from 'node:crypto'
+import {createReadStream} from 'node:fs'
+import {mkdir,mkdtemp,readFile,writeFile,rename,rm,stat} from 'node:fs/promises'
+import {join,dirname,resolve} from 'node:path'
+import {tmpdir} from 'node:os'
+import {spawn} from 'node:child_process'
+import {z} from 'zod'
+import {ContainerComputerProvider} from './container.js'
+
+const imageId=z.string().regex(/^sha256:[a-f0-9]{64}$/)
+export const imageManifest=z.object({protocol:z.literal(1),imageId,architecture:z.enum(['amd64','arm64']),driver:z.literal('0.20.0'),layer:z.literal('5'),createdAt:z.number().int().nonnegative(),archiveSha256:z.string().regex(/^[a-f0-9]{64}$/).optional()}).strict()
+export type ComputerImageManifest=z.infer<typeof imageManifest>
+export async function fileSHA256(path:string){const hash=createHash('sha256');for await(const bytes of createReadStream(path))hash.update(bytes);return hash.digest('hex')}
+export async function writeManifest(path:string,manifest:ComputerImageManifest){
+  const validated=imageManifest.parse(manifest),temporary=path+'.'+randomUUID()
+  await mkdir(dirname(path),{recursive:true,mode:0o700})
+  try{await writeFile(temporary,JSON.stringify(validated,null,2)+'\n',{flag:'wx',mode:0o600});await rename(temporary,path)}finally{await rm(temporary,{force:true})}
+}
+export type ImageCommand=(runtime:'docker'|'podman',args:string[])=>Promise<string>
+const command:ImageCommand=(runtime,args)=>new Promise((resolveResult,reject)=>{
+  const child=spawn(runtime,args,{stdio:['ignore','pipe','pipe']}),output:Buffer[]=[];let bytes=0,error:Error|undefined
+  const timer=setTimeout(()=>{error=new Error('镜像操作超时,请核对运行时后重试');child.kill()},15*60*1000)
+  child.stdout.on('data',chunk=>{bytes+=chunk.length;if(bytes<=4*1024*1024)output.push(chunk);else{error=new Error('镜像输出过大');child.kill()}})
+  child.stderr.on('data',chunk=>process.stderr.write(chunk))
+  child.once('error',cause=>{error=cause})
+  child.once('close',code=>{clearTimeout(timer);if(error||code!==0)reject(error??new Error(`镜像操作失败(${code})`));else resolveResult(Buffer.concat(output).toString())})
+})
+/** Image operations never rewrite a running environment or a Runner configuration. */
+export class ComputerImages {
+  constructor(readonly runtime:'docker'|'podman',readonly run:ImageCommand=command){}
+  async inspect(id:string):Promise<ComputerImageManifest>{
+    imageId.parse(id)
+    const image=JSON.parse(await this.run(this.runtime,['image','inspect',id]))[0]
+    if(image?.Id!==id||image.Os!=='linux'||image.Config?.Labels?.['com.openmausbot.cua-driver']!=='0.20.0'||image.Config?.Labels?.['com.openmausbot.image-layer']!=='5')throw new Error('镜像身份或电脑驱动不兼容')
+    return imageManifest.parse({protocol:1,imageId:id,architecture:image.Architecture,driver:'0.20.0',layer:'5',createdAt:Date.now()})
+  }
+  async prepare(recipe:string):Promise<ComputerImageManifest>{
+    const tag=`localhost/yaoyao/computer:prepare-${randomUUID()}`
+    await this.run(this.runtime,['build','--pull=false','--tag',tag,resolve(recipe)])
+    const id=JSON.parse(await this.run(this.runtime,['image','inspect',tag]))[0]?.Id
+    const manifest=await this.inspect(id)
+    await this.verify(manifest)
+    return manifest
+  }
+  async verify(manifest:ComputerImageManifest){
+    const home=await mkdtemp(join(tmpdir(),'yaoyao-image-check-')),provider=new ContainerComputerProvider(this.runtime,randomUUID(),home)
+    const spec={id:randomUUID(),ownerKey:'image-verification',imageId:manifest.imageId}
+    let stopped=false
+    try{
+      await provider.ensure(spec,()=>{})
+      const deadline=Date.now()+60000
+      while(true){
+        try{await provider.health(spec,()=>{});await provider.capture(spec,()=>{});break}
+        catch(error){if(Date.now()>=deadline)throw new Error('镜像已构建,但电脑驱动未在 60 秒内就绪:'+String((error as {stderr?:string}).stderr??error).slice(0,2000));await new Promise(resolve=>setTimeout(resolve,500))}
+      }
+    }
+    finally{try{await provider.remove(spec);stopped=true}finally{if(stopped)await rm(home,{recursive:true,force:true})}}
+  }
+  async export(id:string,archive:string){
+    const manifest=await this.inspect(id),temporary=archive+'.'+randomUUID()
+    // Exclusive reservation avoids overwriting a user archive on a repeated command.
+    await writeFile(archive,'',{flag:'wx',mode:0o600})
+    try{await this.run(this.runtime,['save','--output',temporary,id]);await rename(temporary,archive);manifest.archiveSha256=await fileSHA256(archive);await writeManifest(archive+'.json',manifest);return manifest}
+    catch(error){await rm(archive,{force:true});throw error}finally{await rm(temporary,{force:true})}
+  }
+  async import(archive:string){
+    const manifest=imageManifest.parse(JSON.parse(await readFile(archive+'.json','utf8')))
+    if(!manifest.archiveSha256||(await stat(archive)).size>20*1024**3||await fileSHA256(archive)!==manifest.archiveSha256)throw new Error('镜像归档校验失败,未导入')
+    await this.run(this.runtime,['load','--input',archive])
+    const actual=await this.inspect(manifest.imageId)
+    if(actual.architecture!==manifest.architecture)throw new Error('镜像架构与清单不一致')
+    await this.verify(actual);return actual
+  }
+}

+ 70 - 0
src/runner/computers/maintenance.ts

@@ -0,0 +1,70 @@
+import {mkdir,readdir,lstat,readlink,readFile,writeFile,rm,realpath} from 'node:fs/promises'
+import {join,resolve,dirname,relative,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 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){}
+  private async stopped(spec:ComputerSpecification){
+    const row=this.pool.status(spec.ownerKey).find(row=>row.environmentId===spec.id)
+    if(!row||row.status!=='free')throw new Error('电脑停止状态未确认,不能维护工作区')
+    await this.provider.remove(spec)
+    const base=join(this.home,'computer-workspaces'),path=join(base,spec.id)
+    await recoverWorkspace(base,spec.id)
+    const [root,detail]=await Promise.all([realpath(base),lstat(path)])
+    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}
+  }
+  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')))
+    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('备份内容校验失败,未替换工作区')
+    const staging=join(dirname(workspace),`.restore-${randomUUID()}`),previous=join(dirname(workspace),`.before-restore-${spec.id}-${randomUUID()}`)
+    try{
+      await copyWorkspace(sourceWorkspace,staging)
+      if(JSON.stringify(await this.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('恢复前数据备份校验失败')
+      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 rm(journal)
+      return {restored:workspace,previousWorkspace:previous,imageId:spec.imageId}
+    }finally{await rm(staging,{recursive:true,force:true})}
+  }
+  async rebuild(spec:ComputerSpecification){const workspace=await this.stopped(spec);return {workspace,rebuildOnNextStart:true,imageId:spec.imageId}}
+}

+ 34 - 0
src/runner/computers/maintenanceCli.ts

@@ -0,0 +1,34 @@
+import {parseArgs} from 'node:util'
+import {readFileSync,statSync} from 'node:fs'
+import {resolve,dirname,join} from 'node:path'
+import {DatabaseSync} from 'node:sqlite'
+import {z} from 'zod'
+import {parseRunnerConfiguration} from '../config.js'
+import {acquireServiceInstance} from '../../server/serviceInstance.js'
+import {ContainerComputerProvider} from './container.js'
+import {ComputerPool} from './pool.js'
+import {ComputerMaintenance} from './maintenance.js'
+let lock:ReturnType<typeof acquireServiceInstance>|undefined,db:DatabaseSync|undefined,pool:ComputerPool|undefined
+try{
+  const {values,positionals}=parseArgs({allowPositionals:true,options:{config:{type:'string'},home:{type:'string'},environment:{type:'string'},snapshot:{type:'string'}}})
+  if(!values.config||!['list','backup','restore','rebuild'].includes(positionals[0]??''))throw new Error('用法:runner-maintenance list|backup|restore|rebuild --config runner.json [--home Runner数据目录] [--environment UUID] [--snapshot 备份目录]')
+  if(process.platform!=='win32'&&(statSync(values.config).mode&0o077)!==0)throw new Error('执行节点配置必须仅当前用户可读')
+  const config=parseRunnerConfiguration(JSON.parse(readFileSync(values.config,'utf8'))),home=values.home?resolve(values.home):resolve(dirname(values.config),'state',config.runnerId)
+  if(!config.computers)throw new Error('该节点未配置电脑')
+  lock=acquireServiceInstance(home,'1.0.0')
+  db=new DatabaseSync(join(home,'runner-commands.sqlite3'))
+  const provider=new ContainerComputerProvider(config.computers.runtime,config.runnerId,home)
+  pool=new ComputerPool(db,provider);await pool.recover()
+  const entries=(db.prepare('SELECT value FROM computer_environments').all() as {value:string}[]).map(row=>JSON.parse(row.value))
+  let result
+  if(positionals[0]==='list')result=entries.map(row=>({environmentId:row.spec.id,status:row.status,imageId:row.spec.imageId}))
+  else{
+    const id=z.string().uuid().parse(values.environment),entry=entries.find(row=>row.spec.id===id)
+    if(!entry)throw new Error('当前 Runner 中没有这台电脑')
+    const maintenance=new ComputerMaintenance(pool,provider,home)
+    if(positionals[0]==='rebuild')result=await maintenance.rebuild(entry.spec)
+    else{if(!values.snapshot)throw new Error('需要 --snapshot 备份目录');result=positionals[0]==='backup'?await maintenance.backup(entry.spec,values.snapshot):await maintenance.restore(entry.spec,values.snapshot)}
+  }
+  process.stdout.write(JSON.stringify(result,null,2)+'\n')
+}catch(error){process.stderr.write(`${error instanceof Error?error.message:'电脑维护失败'}\n`);process.exitCode=1}
+finally{try{await pool?.close()}catch{process.stderr.write('尚未确认所有电脑已停止,请核对运行时\n');process.exitCode=1}db?.close();lock?.release()}

+ 32 - 0
src/runner/computers/workspaceRecovery.ts

@@ -0,0 +1,32 @@
+import {readFile,rename,rm,lstat,readdir,cp} from 'node:fs/promises'
+import {join} from 'node:path'
+import {z} from 'zod'
+const uuid='[0-9a-f-]{36}'
+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 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)
+}
+/** Keep the bind-mount root inode stable on macOS shared filesystems. */
+export async function recoverWorkspace(base:string,id:string){
+  z.string().uuid().parse(id)
+  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 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'){
+    if(!await exists(previous))throw new Error('恢复日志中的原工作区缺失,需要手动核对')
+    if(await exists(workspace))await replaceWorkspaceContents(previous,workspace)
+    else await copyWorkspace(previous,workspace)
+  }else if(!await exists(workspace)){
+    if(!await exists(previous))throw new Error('恢复日志中的原工作区缺失,需要手动核对')
+    await rename(previous,workspace)
+  }
+  await rm(staging,{recursive:true,force:true});await rm(journal)
+}

+ 11 - 1
tests/live/computer.live.test.ts

@@ -4,6 +4,7 @@ import {mkdtemp,rm,readFile,writeFile,mkdir} from 'node:fs/promises'
 import {tmpdir} from 'node:os'
 import {tmpdir} from 'node:os'
 import {join} from 'node:path'
 import {join} from 'node:path'
 import {DatabaseSync} from 'node:sqlite'
 import {DatabaseSync} from 'node:sqlite'
+import {ComputerMaintenance} from '../../src/runner/computers/maintenance'
 import {ComputerPool} from '../../src/runner/computers/pool'
 import {ComputerPool} from '../../src/runner/computers/pool'
 import {randomUUID} from 'node:crypto'
 import {randomUUID} from 'node:crypto'
 import {ContainerComputerProvider,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../../src/runner/computers/container'
 import {ContainerComputerProvider,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../../src/runner/computers/container'
@@ -47,5 +48,14 @@ it.skipIf(!process.env.YAOYAO_COMPUTER_IMAGE)('executes in a real private comput
     expect(await provider.inspect(spec)).toBeUndefined()
     expect(await provider.inspect(spec)).toBeUndefined()
     expect(()=>pool.use(lease,async()=>{})).toThrow('电脑控制权已改变')
     expect(()=>pool.use(lease,async()=>{})).toThrow('电脑控制权已改变')
     await expect(readFile(join(initial.workspace,'cancelled-proof.txt'))).rejects.toMatchObject({code:'ENOENT'})
     await expect(readFile(join(initial.workspace,'cancelled-proof.txt'))).rejects.toMatchObject({code:'ENOENT'})
-  }finally{await pool.close();await provider.remove(spec);db.close();await rm(home,{recursive:true,force:true})}
+    const maintenance=new ComputerMaintenance(pool,provider,home),snapshot=join(home,'snapshot')
+    await maintenance.backup(spec,snapshot)
+    await writeFile(join(initial.workspace,'persistence-proof.txt'),'changed-after-backup')
+    const restored=await maintenance.restore(spec,snapshot)
+    expect(await readFile(join(restored.previousWorkspace,'persistence-proof.txt'),'utf8')).toBe('changed-after-backup')
+    const restoredLease=await pool.acquire(spec,'restored-task',()=>{})
+    expect((await provider.execute(spec,['cat','persistence-proof.txt'],{authorize:()=>{}})).stdout).toBe('isolated-fixture')
+    await pool.release(restoredLease)
+
+  }catch(error){console.error('电脑验收错误',String((error as any).stderr??''),String((error as any).stdout??''));throw error}finally{await pool.close();await provider.remove(spec);db.close();await rm(home,{recursive:true,force:true})}
 },100000)
 },100000)

+ 40 - 0
tests/server/computerImages.test.ts

@@ -0,0 +1,40 @@
+// @vitest-environment node
+import {it,expect,vi} from 'vitest'
+import {mkdtemp,rm,writeFile,readFile} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {ComputerImages,fileSHA256,imageManifest,writeManifest} from '../../src/runner/computers/images'
+const id='sha256:'+'a'.repeat(64)
+const detail={Id:id,Os:'linux',Architecture:'arm64',Config:{Labels:{'com.openmausbot.cua-driver':'0.20.0','com.openmausbot.image-layer':'5'}}}
+it('checks immutable identity, architecture and driver before accepting an image',async()=>{
+  const run=vi.fn().mockResolvedValue(JSON.stringify([detail])),images=new ComputerImages('docker',run)
+  expect(await images.inspect(id)).toMatchObject({protocol:1,imageId:id,architecture:'arm64'})
+  await expect(images.inspect('mutable:latest')).rejects.toThrow()
+  run.mockResolvedValue(JSON.stringify([{...detail,Id:'sha256:'+'b'.repeat(64)}]))
+  await expect(images.inspect(id)).rejects.toThrow('身份')
+  run.mockResolvedValue(JSON.stringify([{...detail,Architecture:'unknown'}]))
+  await expect(images.inspect(id)).rejects.toThrow()
+})
+it('rejects damaged offline archives before invoking the engine',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-image-')),path=join(home,'image.tar'),run=vi.fn()
+  try{
+    await writeFile(path,'fixture image')
+    const manifest=imageManifest.parse({protocol:1,imageId:id,architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1,archiveSha256:await fileSHA256(path)})
+    await writeManifest(path+'.json',manifest);await writeFile(path,'damaged image')
+    await expect(new ComputerImages('docker',run).import(path)).rejects.toThrow('校验失败')
+    expect(run).not.toHaveBeenCalled()
+    expect(JSON.parse(await readFile(path+'.json','utf8'))).toEqual(manifest)
+  }finally{await rm(home,{recursive:true,force:true})}
+})
+it('never overwrites an existing archive and health-checks imported identity',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-image-')),path=join(home,'image.tar')
+  const run=vi.fn().mockResolvedValue(JSON.stringify([detail])),images=new ComputerImages('podman',run),verify=vi.spyOn(images,'verify').mockResolvedValue()
+  try{
+    await writeFile(path,'original')
+    await expect(images.export(id,path)).rejects.toMatchObject({code:'EEXIST'})
+    expect(await readFile(path,'utf8')).toBe('original')
+    await writeManifest(path+'.json',{protocol:1,imageId:id,architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1,archiveSha256:await fileSHA256(path)})
+    await images.import(path)
+    expect(run).toHaveBeenCalledWith('podman',['load','--input',path]);expect(verify).toHaveBeenCalledOnce()
+  }finally{await rm(home,{recursive:true,force:true})}
+})

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

@@ -0,0 +1,39 @@
+// @vitest-environment node
+import {it,expect,vi} from 'vitest'
+import {mkdtemp,mkdir,writeFile,readFile,rm,symlink,lstat} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,basename} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {ComputerMaintenance} from '../../src/runner/computers/maintenance'
+import {recoverWorkspace} from '../../src/runner/computers/workspaceRecovery'
+it('backs up only stopped owned data and validates restore before replacing it',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-maintenance-')),id=randomUUID(),workspace=join(home,'computer-workspaces',id),spec={id,ownerKey:'fixture',imageId:'sha256:'+'a'.repeat(64)}
+  let status='active'
+  const pool={status:()=>[{environmentId:id,status}]},provider={remove:vi.fn().mockResolvedValue(undefined)}
+  const maintenance=new ComputerMaintenance(pool as any,provider as any,home),snapshot=join(home,'snapshot')
+  try{
+    await mkdir(workspace,{recursive:true});await writeFile(join(workspace,'document.txt'),'before');await symlink('/guest/path',join(workspace,'guest-link'))
+    await expect(maintenance.backup(spec,snapshot)).rejects.toThrow('停止状态')
+    status='free';await maintenance.backup(spec,snapshot)
+    await writeFile(join(workspace,'document.txt'),'after')
+    const result=await maintenance.restore(spec,snapshot)
+    expect(await readFile(join(workspace,'document.txt'),'utf8')).toBe('before')
+    expect(await readFile(join(result.previousWorkspace,'document.txt'),'utf8')).toBe('after')
+    expect((await lstat(join(workspace,'guest-link'))).isSymbolicLink()).toBe(true)
+    await writeFile(join(snapshot,'workspace/document.txt'),'damaged')
+    await expect(maintenance.restore(spec,snapshot)).rejects.toThrow('校验失败')
+    expect(await readFile(join(workspace,'document.txt'),'utf8')).toBe('before')
+    await expect(maintenance.backup(spec,snapshot)).rejects.toMatchObject({code:'EEXIST'})
+  }finally{await rm(home,{recursive:true,force:true})}
+})
+it('recovers an interrupted content restore without replacing the bind-mount root',async()=>{
+  const base=await mkdtemp(join(tmpdir(),'yaoyao-restore-')),id=randomUUID(),workspace=join(base,id),previous=join(base,`.before-restore-${id}-${randomUUID()}`),staging=join(base,`.restore-${randomUUID()}`)
+  try{
+    await mkdir(workspace);await writeFile(join(workspace,'file'),'original');await mkdir(staging);await writeFile(join(staging,'file'),'replacement')
+    await writeFile(join(base,`.restore-${id}.json`),JSON.stringify({protocol:1,mode:'copy',previous:basename(previous),staging:basename(staging)}))
+    await mkdir(previous);await writeFile(join(previous,'file'),'original');await writeFile(join(workspace,'file'),'partial replacement');const inode=(await lstat(workspace)).ino;await recoverWorkspace(base,id)
+    expect((await lstat(workspace)).ino).toBe(inode)
+    expect(await readFile(join(workspace,'file'),'utf8')).toBe('original')
+    await expect(lstat(staging)).rejects.toMatchObject({code:'ENOENT'})
+  }finally{await rm(base,{recursive:true,force:true})}
+})

+ 202 - 0
third-party/openmausbot/LICENSE

@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.

+ 4 - 0
third-party/openmausbot/NOTICE

@@ -0,0 +1,4 @@
+The computer image recipe derives from OpenMausBot server/container-computer.ts, commit 886b786c.
+OpenMausBot contributors, Apache License 2.0.
+Changes: standalone Dockerfile, Yaoyao image metadata and persistent Firefox profile preparation.
+CUA, XFCE, browsers, fonts and base image retain their respective upstream licenses.