소스 검색

补齐电脑环境与镜像管理界面及受控操作接口

LSamien 1 개월 전
부모
커밋
bf19b15e70

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

@@ -86,3 +86,6 @@ Runner 默认最多同时运行 2 台电脑,可配置 1–8 台;默认最多
 正式服务部署、真实手机安装、系统重新登录、Podman 实机、amd64 镜像、公证和对外发布分别记录。开发签名的分支 App 不等于已公证的公开发行版。当前实现是容器级隔离,macOS 底层 Linux VM 由 Docker/Podman 提供;独占内核 VM、宿主 macOS CUA 和其他云电脑 Provider 是明确的扩展边界。
 正式服务部署、真实手机安装、系统重新登录、Podman 实机、amd64 镜像、公证和对外发布分别记录。开发签名的分支 App 不等于已公证的公开发行版。当前实现是容器级隔离,macOS 底层 Linux VM 由 Docker/Podman 提供;独占内核 VM、宿主 macOS CUA 和其他云电脑 Provider 是明确的扩展边界。
 
 
 配套说明:`bot-agent-teams.md`、`runner.md`、`computer-provider.md`、`computer-worker.md`、`computer-network.md`、`computer-control.md`、`task-helpers.md`、`shared-computers.md`、`computer-maintenance.md`、`macos-app.md`。
 配套说明:`bot-agent-teams.md`、`runner.md`、`computer-provider.md`、`computer-worker.md`、`computer-network.md`、`computer-control.md`、`task-helpers.md`、`shared-computers.md`、`computer-maintenance.md`、`macos-app.md`。
+
+
+2026-09-09 界面补齐及平台范围以 `computer-management-ui.md` 为准:Web/macOS 提供完整管理入口;手机端仅提供虚拟桌面、共享屏幕的显示和操作。

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

@@ -115,3 +115,6 @@
 Docker 已做真实运行验收;Podman 有接口与离线一致性测试,未做实机运行。远程节点使用相同的主动连接协议,Docker 控制面与容器外 Runner 已验收;没有宣称不同物理主机、全部操作系统版本均已实测。登录启动开关有实现和偏好验证,本轮没有重新登录 macOS。
 Docker 已做真实运行验收;Podman 有接口与离线一致性测试,未做实机运行。远程节点使用相同的主动连接协议,Docker 控制面与容器外 Runner 已验收;没有宣称不同物理主机、全部操作系统版本均已实测。登录启动开关有实现和偏好验证,本轮没有重新登录 macOS。
 
 
 模型循环使用真实 Hermes SDK,模型输出由本地确定性测试服务提供,未调用付费模型。正式 15300 服务、实体手机、参考仓库没有被本轮替换或部署;验收容器、临时数据卷和测试服务均已清理。App 使用 Apple Development 开发签名,未公证、未对外发布。旧 safeStorage 配置若需要钥匙串授权,仍由用户完成正常系统授权。
 模型循环使用真实 Hermes SDK,模型输出由本地确定性测试服务提供,未调用付费模型。正式 15300 服务、实体手机、参考仓库没有被本轮替换或部署;验收容器、临时数据卷和测试服务均已清理。App 使用 Apple Development 开发签名,未公证、未对外发布。旧 safeStorage 配置若需要钥匙串授权,仍由用户完成正常系统授权。
+
+
+2026-09-09 界面补齐及平台范围以 `computer-management-ui.md` 为准:Web/macOS 提供完整管理入口;手机端仅提供虚拟桌面、共享屏幕的显示和操作。

+ 3 - 0
docs/computer-maintenance.md

@@ -45,3 +45,6 @@ CLI 默认数据目录为配置旁的 `state/<runnerId>`。App 托管节点的
 离线测试覆盖驱动/身份/架构不符、损坏归档不调用运行时、拒绝覆盖、在用环境拒绝维护、恢复前校验、保留旧数据及内容恢复中途崩溃回退。Podman 参数一致性有离线证据,真实 Podman 与 amd64 构建尚未验收。
 离线测试覆盖驱动/身份/架构不符、损坏归档不调用运行时、拒绝覆盖、在用环境拒绝维护、恢复前校验、保留旧数据及内容恢复中途崩溃回退。Podman 参数一致性有离线证据,真实 Podman 与 amd64 构建尚未验收。
 
 
 真实电脑备份/恢复后重新启动并读取原文件通过,恢复前文件保留在旧数据副本。新镜像的打包 Runner / Hermes Worker 验收 2 项通过,包含公网访问、临时助手、产物回传、人工接管和原任务续跑。类型检查及 767 项全量测试通过。
 真实电脑备份/恢复后重新启动并读取原文件通过,恢复前文件保留在旧数据副本。新镜像的打包 Runner / Hermes Worker 验收 2 项通过,包含公网访问、临时助手、产物回传、人工接管和原任务续跑。类型检查及 767 项全量测试通过。
+
+
+2026-09-09 界面补齐及平台范围以 `computer-management-ui.md` 为准:Web/macOS 提供完整管理入口;手机端仅提供虚拟桌面、共享屏幕的显示和操作。

+ 34 - 0
docs/computer-management-ui.md

@@ -0,0 +1,34 @@
+# 电脑、镜像与屏幕的界面入口
+
+## 平台边界
+
+- Web/macOS:Bot 模式聊天列表中常驻“电脑与环境”,包含电脑、共享环境、镜像、节点配置。
+- iOS/Android:常驻“桌面与屏幕”,只列出虚拟桌面和共享屏幕,支持查看、接管、输入和交还。没有镜像管理、共享成员变更或执行环境切换入口。
+- 没有电脑、节点未连接、账号无管理权限或服务端版本较旧时,保留入口并显示原因。手机安装新版客户端不会自动更新它连接的服务端。
+
+## Web/macOS 操作
+
+1. 管理员在“节点配置”注册并连接 Runner,勾选隔离电脑 Worker。镜像 ID 可留空,之后在镜像页准备。
+2. 在“镜像”选择执行节点,点击“准备兼容镜像”,或选择 `.tar` 和配套 `.tar.json` 上传导入。界面显示传输进度及节点上的操作记录。
+3. 准备或导入完成后,在镜像列表选择“应用”。界面显示当前镜像及可回退版本。更新和回退保留工作区。
+4. “导出”会生成可分别下载的镜像与校验清单;导出副本可以清理,原镜像保留。未完成上传可以取消清理。
+5. “电脑”列出所有自己的 Agent 及配置状态,可以启用隔离电脑、进入查看/接管面板。共享环境在独立的“共享环境”页创建、查看成员或解除。
+
+镜像管理仅对服务器管理员开放。镜像任务开始前要求节点的电脑资源空闲,期间新电脑任务等待;未确认测试电脑已停止时保留维护锁,刷新状态可重试清理。后台任务不会因关闭页面自动重复执行,Runner 重启后原未完成任务显示待核对。
+
+## 接口与数据
+
+- `GET /api/app/computers`:当前账号的电脑列表、可用性与原因。
+- `/api/app/computers/shared`:沿用已有账号范围的共享授权。
+- `/api/app/admin/runners/:id/images`:镜像目录、当前/上一个镜像、操作记录。
+- `/images/jobs`:准备、校验、应用、回退、导入、导出;请求编号持久去重。
+- `/images/uploads`:512 KiB 分块传输,上限 20 GiB,校验归档 SHA-256 后导入;不接受宿主路径。
+- `/images/jobs/:id/archive`、`manifest`:下载导出结果;文件在 Runner 侧,控制面流式转发。
+
+Runner 使用 `image-management-v1` 能力协商,镜像选择与任务记录保存在 Runner 的 SQLite 中。应用的镜像覆盖节点配置中的默认镜像,重新连接后保留;重新导入不同默认镜像配置时清除旧覆盖。管理请求经过管理员身份和授权版本检查,模型工具没有这些管理接口或凭据。
+
+## 验证
+
+Web 流程覆盖常驻入口、未配置提示、应用/回退、导出下载及旧服务提示,并检查窄屏深色截图。手机 UI 流程确认没有管理按钮,分别显示独立/共享屏幕并完成接管交还。已有 iOS 28 项 Workspace 测试通过,Android 编译与原生 UI 流程通过。
+
+真实 Docker + 打包 Runner + 控制面已完成镜像准备、校验、应用、回退,并导出、分块下载/上传及重新导入约 500 MB 的兼容镜像,核对 SHA-256。镜像完成后继续执行原有 Worker、共享电脑和临时助手验收。

+ 3 - 0
docs/shared-computers.md

@@ -31,3 +31,6 @@ Web 与 macOS 提供创建/解除入口。iOS、Android 显示共享名称、资
 python3 tests/fixtures/native-computer.py --port 18842 --image public/icons/icon-512.png # iOS
 python3 tests/fixtures/native-computer.py --port 18842 --image public/icons/icon-512.png # iOS
 python3 tests/fixtures/native-computer.py --port 18841 --image public/icons/icon-512.png # Android
 python3 tests/fixtures/native-computer.py --port 18841 --image public/icons/icon-512.png # Android
 ```
 ```
+
+
+2026-09-09 界面补齐及平台范围以 `computer-management-ui.md` 为准:Web/macOS 提供完整管理入口;手机端仅提供虚拟桌面、共享屏幕的显示和操作。

+ 5 - 4
src/client/components/app/RunnerSettingsPanel.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import { onMounted, ref } from 'vue'
 import { onMounted, ref } from 'vue'
 import { apiRequest } from '@/api/client'
 import { apiRequest } from '@/api/client'
+import { UNCONFIGURED_COMPUTER_IMAGE } from '@shared/runner'
 import type { RunnerConfiguration, RunnerRecord } from '@shared/runner'
 import type { RunnerConfiguration, RunnerRecord } from '@shared/runner'
 
 
 type Runner = Omit<RunnerRecord, 'tokenHash' | 'sourceOwner'> & {online: boolean}
 type Runner = Omit<RunnerRecord, 'tokenHash' | 'sourceOwner'> & {online: boolean}
@@ -29,9 +30,9 @@ async function register() {
     if(web.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(web.hostname)&&!insecure.value)throw new Error('远程节点请使用 HTTPS,或勾选可信局域网 HTTP')
     if(web.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(web.hostname)&&!insecure.value)throw new Error('远程节点请使用 HTTPS,或勾选可信局域网 HTTP')
     if(!['127.0.0.1','[::1]'].includes(local.hostname)||!['http:','https:'].includes(local.protocol)||local.username||local.password)throw new Error('Hermes 地址必须指向 Runner 所在电脑的回环地址')
     if(!['127.0.0.1','[::1]'].includes(local.hostname)||!['http:','https:'].includes(local.protocol)||local.username||local.password)throw new Error('Hermes 地址必须指向 Runner 所在电脑的回环地址')
     const allowedProfiles = [...new Set(profiles.value.split(/[\s,,]+/).filter(Boolean))]
     const allowedProfiles = [...new Set(profiles.value.split(/[\s,,]+/).filter(Boolean))]
-    if(computers.value&&(!/^sha256:[a-f0-9]{64}$/.test(computerImage.value)||[computerPython.value,hermesSource.value,hermesHome.value].some(path=>!path.startsWith('/'))))throw new Error('请填写镜像完整 ID 和执行电脑上的绝对路径')
+    if(computers.value&&((!!computerImage.value&&!/^sha256:[a-f0-9]{64}$/.test(computerImage.value))||[computerPython.value,hermesSource.value,hermesHome.value].some(path=>!path.startsWith('/'))))throw new Error('请填写镜像完整 ID 和执行电脑上的绝对路径')
     const result = await apiRequest<{runner:Runner;token:string}>('/api/app/admin/runners', {method:'POST',body:{name:name.value.trim(),sourceNodeId:source.value,allowedProfiles}})
     const result = await apiRequest<{runner:Runner;token:string}>('/api/app/admin/runners', {method:'POST',body:{name:name.value.trim(),sourceNodeId:source.value,allowedProfiles}})
-    configuration.value = {protocol:1,serverURL:web.origin,runnerId:result.runner.id,token:result.token,hermesURL:local.toString(),allowedProfiles,artifactRoots:artifactRoots.value.split('\n').map(p=>p.trim()).filter(Boolean),...(insecure.value?{allowInsecureLan:true}:{}),...(computers.value?{computers:{network:computerNetwork.value,runtime:computerRuntime.value,imageId:computerImage.value,python:computerPython.value,hermesSource:hermesSource.value,hermesHome:hermesHome.value}}:{})}
+    configuration.value = {protocol:1,serverURL:web.origin,runnerId:result.runner.id,token:result.token,hermesURL:local.toString(),allowedProfiles,artifactRoots:artifactRoots.value.split('\n').map(p=>p.trim()).filter(Boolean),...(insecure.value?{allowInsecureLan:true}:{}),...(computers.value?{computers:{network:computerNetwork.value,runtime:computerRuntime.value,imageId:computerImage.value||UNCONFIGURED_COMPUTER_IMAGE,python:computerPython.value,hermesSource:hermesSource.value,hermesHome:hermesHome.value}}:{})}
     notice.value = '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。'
     notice.value = '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。'
     await refresh()
     await refresh()
   })
   })
@@ -77,11 +78,11 @@ onMounted(()=>{void action(refresh)})
         <label>容器运行时<select v-model="computerRuntime"><option value="docker">Docker</option><option value="podman">Podman</option></select></label>
         <label>容器运行时<select v-model="computerRuntime"><option value="docker">Docker</option><option value="podman">Podman</option></select></label>
         <label>联网范围<select v-model="computerNetwork"><option value="public-proxy">公网网页(HTTP / HTTPS)</option><option value="none">不联网</option></select></label>
         <label>联网范围<select v-model="computerNetwork"><option value="public-proxy">公网网页(HTTP / HTTPS)</option><option value="none">不联网</option></select></label>
         <small>公网模式通过受控代理访问,不开放宿主和内网地址。</small>
         <small>公网模式通过受控代理访问,不开放宿主和内网地址。</small>
-        <label>兼容镜像 ID<input v-model.trim="computerImage" required placeholder="sha256:完整镜像 ID"></label>
+        <label>兼容镜像 ID<input v-model.trim="computerImage" placeholder="可稍后在镜像页面准备和应用"></label>
         <label>Hermes Python 路径<input v-model.trim="computerPython" required placeholder="/完整路径/hermes-agent/venv/bin/python"></label>
         <label>Hermes Python 路径<input v-model.trim="computerPython" required placeholder="/完整路径/hermes-agent/venv/bin/python"></label>
         <label>Hermes 源码目录<input v-model.trim="hermesSource" required placeholder="/完整路径/hermes-agent"></label>
         <label>Hermes 源码目录<input v-model.trim="hermesSource" required placeholder="/完整路径/hermes-agent"></label>
         <label>Hermes 配置目录<input v-model.trim="hermesHome" required placeholder="/完整路径/.hermes"></label>
         <label>Hermes 配置目录<input v-model.trim="hermesHome" required placeholder="/完整路径/.hermes"></label>
-        <small>在 Agent 设置中选择“隔离电脑”后使用。当前使用 CUA 0.20.0 layer 5 兼容镜像,镜像安装管理仍在完善。</small>
+        <small>在 Agent 设置中选择“隔离电脑”后使用。当前使用 CUA 0.20.0 layer 5 兼容镜像,注册并连接后,可在“电脑与环境 → 镜像”中准备、导入和应用镜像。</small>
       </fieldset>
       </fieldset>
       <button class="solid-button" :disabled="busy || !name.trim() || !profiles.trim()">注册节点</button>
       <button class="solid-button" :disabled="busy || !name.trim() || !profiles.trim()">注册节点</button>
     </form>
     </form>

+ 44 - 0
src/client/components/workspace/ComputerEnvironmentHub.vue

@@ -0,0 +1,44 @@
+<script setup lang="ts">
+import {ref,computed,onMounted,onBeforeUnmount} from 'vue'
+import {apiRequest} from '@/api/client'
+import {createUuid} from '@/utils/id'
+import type {WorkspaceAgent} from '@shared/workspace'
+import ComputerPanel from './ComputerPanel.vue'
+import ImageManagementPanel from './ImageManagementPanel.vue'
+import RunnerSettingsPanel from '@/components/app/RunnerSettingsPanel.vue'
+const emit=defineEmits<{close:[];changed:[]}>(),dialog=ref<HTMLDialogElement>(),tab=ref('computers'),loading=ref(false),busy=ref(false),error=ref(''),unsupported=ref(false),canManageImages=ref(false)
+const computers=ref<Array<{agent:WorkspaceAgent;available:boolean;reason:string}>>([]),shared=ref<Array<{id:string;name:string;memberIds:string[]}>>([]),viewing=ref<WorkspaceAgent>(),name=ref('团队共享电脑'),members=ref<string[]>([]),trusted=ref(false)
+let closed=false
+const candidates=computed(()=>computers.value.filter(row=>row.agent.execution==='computer'&&!row.agent.temporaryGoalId&&!row.agent.computerEnvironmentId))
+async function refresh(){loading.value=true;error.value='';try{const data=await apiRequest<{computers:typeof computers.value;canManageImages:boolean}>('/api/app/computers');if(closed)return;computers.value=data.computers;canManageImages.value=data.canManageImages;shared.value=(await apiRequest<{computers:typeof shared.value}>('/api/app/computers/shared')).computers;unsupported.value=false}catch(e){unsupported.value=Number((e as any).status)===404;error.value=unsupported.value?'当前连接的服务尚未提供电脑管理,请切换到配套分支服务或更新服务端。':e instanceof Error?e.message:'无法读取电脑环境'}finally{loading.value=false}}
+async function action(work:()=>Promise<unknown>){if(busy.value)return;busy.value=true;error.value='';try{await work();emit('changed');await refresh()}catch(e){error.value=e instanceof Error?e.message:'操作失败'}finally{busy.value=false}}
+function enable(agent:WorkspaceAgent){void action(()=>apiRequest(`/api/app/agents/${agent.id}`,{method:'PATCH',body:{execution:'computer'}}))}
+function share(){void action(async()=>{await apiRequest('/api/app/computers/shared',{method:'POST',body:{requestId:createUuid(),name:name.value,memberIds:members.value,trusted:trusted.value}});members.value=[];trusted.value=false})}
+onMounted(()=>{dialog.value?.showModal();void refresh()});onBeforeUnmount(()=>{closed=true})
+</script>
+<template>
+ <Teleport to="body"><dialog ref="dialog" class="environment-hub" aria-label="电脑与环境" @cancel.prevent="!viewing&&!busy&&emit('close')">
+  <header><div><h2>电脑与环境</h2><p>管理电脑、镜像与共享成员</p></div><button :disabled="busy||!!viewing" @click="emit('close')">关闭</button></header>
+  <nav aria-label="环境管理分类"><button v-for="item in [{id:'computers',name:'电脑'},{id:'shared',name:'共享环境'},{id:'images',name:'镜像'},{id:'nodes',name:'节点配置'}]" :key="item.id" :aria-pressed="tab===item.id" @click="tab=item.id">{{item.name}}</button></nav>
+  <main>
+   <div class="actions"><button :disabled="loading||busy" @click="refresh">刷新状态</button><span v-if="loading" role="status">正在读取…</span></div>
+   <p v-if="error" role="alert">{{error}}</p>
+   <p v-if="unsupported">安装新版手机 App 不会自动更新服务端。请核对当前连接的服务地址;本页面会保留,更新后点击刷新。</p>
+   <template v-else-if="tab==='computers'">
+    <p v-if="!computers.length&&!loading">还没有 Agent。先在 Bot 模式新建 Agent,再在这里启用隔离电脑。</p>
+    <article v-for="row in computers" :key="row.agent.id"><h3>{{row.agent.name}}</h3><p>{{row.agent.computerEnvironmentName?'共享电脑 · '+row.agent.computerEnvironmentName:row.agent.execution==='computer'?'独立电脑':'尚未配置电脑'}}</p><p>{{row.reason}}</p><div class="actions"><button v-if="row.agent.execution==='computer'" :disabled="!row.available||busy" @click="viewing=row.agent">查看与接管</button><button v-else :disabled="busy||!!row.agent.remoteAgentId" @click="enable(row.agent)">启用隔离电脑</button><button v-if="!row.available" @click="tab=canManageImages?'nodes':'images'">查看配置指引</button></div></article>
+   </template>
+   <template v-else-if="tab==='shared'">
+    <p>共享成员可以访问同一工作区与浏览器登录资料;电脑由一名操作者使用,其他任务排队。</p>
+    <p v-if="!shared.length">还没有共享环境。默认每个 Agent 使用独立电脑。</p>
+    <article v-for="item in shared" :key="item.id"><h3>{{item.name}}</h3><p>{{item.memberIds.map(id=>computers.find(row=>row.agent.id===id)?.agent.name||id).join('、')}}</p><button :disabled="busy" @click="action(()=>apiRequest(`/api/app/computers/shared/${item.id}`,{method:'DELETE'}))">解除共享并保留数据</button></article>
+    <fieldset><legend>创建共享环境</legend><label>名称<input v-model="name" maxlength="100"></label><p v-if="candidates.length<2">至少需要两名使用同一来源和 Profile 的持久隔离 Agent。请先在电脑页完成配置。</p><label v-for="row in candidates" :key="row.agent.id" class="check"><input v-model="members" type="checkbox" :value="row.agent.id">{{row.agent.name}} · {{row.agent.profile}}</label><label class="check"><input v-model="trusted" type="checkbox">我确认这些成员可以共享工作文件和浏览器登录资料</label><button :disabled="busy||members.length<2||members.length>8||!name.trim()||!trusted" @click="share">创建共享环境</button></fieldset>
+   </template>
+   <template v-else-if="tab==='images'"><ImageManagementPanel v-if="canManageImages"/><p v-else>镜像管理需要服务器管理员权限。你仍可在电脑页查看和接管自己的电脑,在共享环境页管理自己的成员。</p></template>
+   <template v-else><RunnerSettingsPanel v-if="canManageImages"/><p v-else>请让管理员注册执行节点、启用隔离电脑并分配 Profile,然后刷新此页面。</p></template>
+  </main>
+ </dialog><ComputerPanel v-if="viewing" :agents="[viewing]" @close="viewing=undefined" @changed="refresh"/></Teleport>
+</template>
+<style scoped>
+.environment-hub{width:min(900px,calc(100vw - 24px));max-height:calc(100dvh - 24px);padding:0;border:1px solid var(--line);border-radius:16px;background:var(--surface);color:var(--text-primary)}.environment-hub::backdrop{background:#0007}header{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:18px;border-bottom:1px solid var(--line)}h2,h3,p{margin:0}h2{font-size:18px}h3{font-size:15px}p{font-size:13px;line-height:1.7;color:var(--text-secondary)}nav,.actions{display:flex;gap:8px;flex-wrap:wrap}nav{padding:12px 18px;border-bottom:1px solid var(--line)}main{display:grid;gap:16px;padding:18px;min-width:0}article,fieldset{display:grid;gap:10px;padding:16px;border:1px solid var(--line);border-radius:12px;min-width:0}label{display:grid;gap:8px}.check{display:flex;align-items:center;min-height:44px}.check input{width:18px;height:18px}button,input{font:inherit;color:inherit}button,input:not([type=checkbox]){min-height:44px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface-raised)}button{cursor:pointer}button[aria-pressed=true]{border-color:var(--accent);background:var(--surface-soft)}button:disabled{opacity:.5}button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}[role=alert]{color:var(--danger)}@media(max-width:600px){.environment-hub{width:100vw;max-height:100dvh;border-radius:0}main,header,nav{padding:12px}}
+</style>

+ 50 - 0
src/client/components/workspace/ImageManagementPanel.vue

@@ -0,0 +1,50 @@
+<script setup lang="ts">
+import {ref,onMounted,onBeforeUnmount} from 'vue'
+import {apiRequest} from '@/api/client'
+import {createUuid} from '@/utils/id'
+import type {ImageInventory,ImageOperation,ImageJob} from '@shared/imageManagement'
+const runners=ref<Array<{id:string;name:string;online:boolean;enabled:boolean}>>([]),selected=ref(''),inventory=ref<ImageInventory>(),error=ref(''),busy=ref(false),loading=ref(false),progress=ref('')
+const archive=ref<File>(),manifest=ref<File>(),uploadId=ref('');let closed=false,timer:ReturnType<typeof setTimeout>,generation=0
+const base=()=>`/api/app/admin/runners/${encodeURIComponent(selected.value)}/images`
+const names:Record<ImageOperation,string>={prepare:'准备兼容镜像',verify:'校验',activate:'应用',rollback:'回退',import:'导入',export:'导出'}
+async function refresh(){if(!selected.value||busy.value||closed)return;const current=++generation;loading.value=true;try{const data=await apiRequest<ImageInventory>(base());if(current===generation)inventory.value=data}catch(e){if(current===generation)error.value=e instanceof Error?e.message:'无法读取镜像'}finally{loading.value=false}}
+async function action(operation:ImageOperation,imageId?:string){if(busy.value)return;busy.value=true;error.value='';try{await apiRequest(base()+'/jobs',{method:'POST',body:{requestId:createUuid(),operation,...(imageId?{imageId}:{}),...(operation==='import'?{uploadId:uploadId.value}:{})}})}catch(e){error.value=e instanceof Error?e.message:'镜像操作失败'}finally{busy.value=false;await refresh()}}
+async function upload(){
+ if(!archive.value||!manifest.value||busy.value)return;busy.value=true;error.value='';const file=archive.value;const endpoint=base();let success=false
+ try{
+  if(file.size>20*1024**3||manifest.value.size>16000)throw new Error('镜像上限 20 GiB;请选择配套的 JSON 清单')
+  const metadata=JSON.parse(await manifest.value.text());uploadId.value=createUuid()
+  await apiRequest(endpoint+'/uploads',{method:'POST',body:{id:uploadId.value,size:file.size,manifest:metadata}})
+  for(let offset=0;offset<file.size;){if(closed)throw new Error('上传已取消');const bytes=new Uint8Array(await file.slice(offset,offset+524288).arrayBuffer());let binary='';for(const byte of bytes)binary+=String.fromCharCode(byte)
+   const next=await apiRequest<{offset:number}>(endpoint+'/uploads/'+uploadId.value,{method:'POST',body:{offset,data:btoa(binary)}});if(next.offset<=offset)throw new Error('上传未能继续');offset=next.offset;progress.value=`正在上传 ${Math.round(offset/file.size*100)}%`
+  }success=true;progress.value='上传完成,正在提交导入'
+ }catch(e){error.value=e instanceof Error?e.message:'上传失败';if(uploadId.value)await apiRequest(endpoint+'/uploads/'+uploadId.value,{method:'DELETE'}).catch(()=>{})}
+ finally{busy.value=false}
+ if(success){await action('import');archive.value=undefined;manifest.value=undefined;progress.value=''}
+}
+function choose(event:Event,kind:'archive'|'manifest'){const file=(event.target as HTMLInputElement).files?.[0];if(kind==='archive')archive.value=file;else manifest.value=file}
+async function poll(){await refresh();if(!closed)timer=setTimeout(poll,2000)}
+onMounted(async()=>{try{runners.value=(await apiRequest<{runners:typeof runners.value}>('/api/app/admin/runners')).runners.filter(r=>r.enabled);selected.value=runners.value[0]?.id??'';void poll()}catch(e){error.value=e instanceof Error?e.message:'无法读取节点'}})
+onBeforeUnmount(()=>{closed=true;generation++;clearTimeout(timer)})
+</script>
+<template>
+ <section class="image-manager" aria-label="镜像管理">
+  <p>操作提交后在节点后台继续,可关闭页面稍后查看记录。</p>
+  <p>在执行节点上准备和维护电脑镜像。应用或回退会保留工作区;请先停止该节点的电脑任务并交还控制权。</p>
+  <p v-if="!runners.length&&!error">尚未注册执行节点。请先在“节点配置”中注册并连接 Runner,镜像 ID 可以稍后设置。</p>
+  <label v-else>执行节点<select v-model="selected" :disabled="busy||inventory?.busy" @change="inventory=undefined;error='';refresh()"><option v-for="runner in runners" :key="runner.id" :value="runner.id">{{runner.name}}{{runner.online?'':'(离线)'}}</option></select></label>
+  <p v-if="error" role="alert">{{error}}</p>
+  <template v-if="selected">
+   <div class="actions"><button :disabled="busy||inventory?.busy" @click="action('prepare')">准备兼容镜像</button><button :disabled="busy||loading" @click="error='';refresh()">刷新</button><button v-if="inventory?.previousImageId" :disabled="busy||inventory?.busy" @click="action('rollback')">回退到上一镜像</button></div>
+   <p v-if="loading&&!inventory" role="status">正在读取节点镜像…</p>
+   <p v-if="inventory&&!inventory.images.length">节点还没有兼容镜像。可以在线准备,或导入已有镜像和校验清单。</p>
+   <article v-for="image in inventory?.images" :key="image.imageId" class="image-card"><strong>{{image.architecture}} · CUA {{image.driver}}</strong><code>{{image.imageId}}</code><span v-if="image.imageId===inventory?.activeImageId">当前使用</span><div class="actions"><button :disabled="busy||inventory?.busy" @click="action('verify',image.imageId)">校验</button><button :disabled="busy||inventory?.busy||image.imageId===inventory?.activeImageId" @click="action('activate',image.imageId)">应用</button><button :disabled="busy||inventory?.busy" @click="action('export',image.imageId)">导出</button></div></article>
+   <article v-for="item in inventory?.uploads" :key="item.id" class="image-card"><p>未完成上传:{{Math.round(item.offset/item.size*100)}}%</p><button :disabled="busy||inventory?.busy" @click="apiRequest(`${base()}/uploads/${item.id}`,{method:'DELETE'}).then(refresh).catch(e=>error=String(e))">取消并清理上传</button></article>
+   <fieldset><legend>导入离线镜像</legend><label>镜像文件(.tar)<input type="file" accept=".tar" :disabled="busy||inventory?.busy" @change="choose($event,'archive')"></label><label>校验清单(.tar.json)<input type="file" accept=".json" :disabled="busy||inventory?.busy" @change="choose($event,'manifest')"></label><button :disabled="busy||inventory?.busy||!archive||!manifest" @click="upload">上传并导入</button><p v-if="progress" role="status">{{progress}}</p></fieldset>
+   <section v-if="inventory?.jobs.length" aria-label="镜像操作记录"><h3>操作记录</h3><article v-for="job in inventory.jobs" :key="job.id" class="image-card"><strong>{{names[job.operation]}} · {{({running:'进行中',complete:'完成',failed:'失败',uncertain:'待核对'})[job.state]}}</strong><p role="status">{{job.message}}</p><div v-if="job.operation==='export'&&job.state==='complete'&&job.archiveSize" class="actions"><a :href="`${base()}/jobs/${job.id}/archive`" download>下载镜像</a><a :href="`${base()}/jobs/${job.id}/manifest`" download>下载校验清单</a><button @click="apiRequest(`${base()}/jobs/${job.id}/archive`,{method:'DELETE'}).then(refresh).catch(e=>error=String(e))">清理导出文件</button></div></article></section>
+  </template>
+ </section>
+</template>
+<style scoped>
+.image-manager,.image-card,fieldset,label{display:grid;gap:12px}.image-card,fieldset{padding:16px;border:1px solid var(--line);border-radius:12px;min-width:0}.actions{display:flex;gap:8px;flex-wrap:wrap}p{font-size:13px;line-height:1.6;margin:0;color:var(--text-secondary)}code{overflow-wrap:anywhere;font-size:12px}button,select,input,a{font:inherit;min-height:44px;color:var(--text-primary)}button,select,a{border:1px solid var(--line);border-radius:8px;padding:8px 12px;background:var(--surface-raised)}a{display:inline-flex;align-items:center;text-decoration:none}button:disabled{opacity:.5}button{cursor:pointer}:is(button,select,input,a):focus-visible{outline:2px solid var(--accent);outline-offset:2px}[role=alert]{color:var(--danger)}
+</style>

+ 6 - 2
src/client/views/ConversationsView.vue

@@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
 import RemoteAgentPicker from '@/components/workspace/RemoteAgentPicker.vue'
 import RemoteAgentPicker from '@/components/workspace/RemoteAgentPicker.vue'
 import ConversationList from '@/components/workspace/ConversationList.vue'
 import ConversationList from '@/components/workspace/ConversationList.vue'
+import ComputerEnvironmentHub from '@/components/workspace/ComputerEnvironmentHub.vue'
 import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
 import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
 import TaskPlan from '@/components/workspace/TaskPlan.vue'
 import TaskPlan from '@/components/workspace/TaskPlan.vue'
 import type { AgentAssignment } from '@shared/agentTasks'
 import type { AgentAssignment } from '@shared/agentTasks'
@@ -185,6 +186,7 @@ let cursor = 0,
   pendingFingerprint = ''
   pendingFingerprint = ''
 const selected = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
 const selected = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
 const selectedTask = computed(() => typeof route.query.taskId === 'string' ? route.query.taskId : '')
 const selectedTask = computed(() => typeof route.query.taskId === 'string' ? route.query.taskId : '')
+const environmentOpen=ref(false)
 const computerOpen=ref(false)
 const computerOpen=ref(false)
 const computerAgents=computed(()=>agents.value.filter(agent=>agent.execution==='computer'&&!agent.archived&&(active.value?.memberIds.includes(agent.id)||(!!agent.temporaryGoalId&&agent.temporaryGoalId===selectedTask.value))))
 const computerAgents=computed(()=>agents.value.filter(agent=>agent.execution==='computer'&&!agent.archived&&(active.value?.memberIds.includes(agent.id)||(!!agent.temporaryGoalId&&agent.temporaryGoalId===selectedTask.value))))
 const members = computed(() => agents.value.filter((a) => active.value?.memberIds.includes(a.id)))
 const members = computed(() => agents.value.filter((a) => active.value?.memberIds.includes(a.id)))
@@ -647,10 +649,10 @@ onBeforeUnmount(() => {
     @create-remote-agent="remotePickerOpen = true"
     @create-remote-agent="remotePickerOpen = true"
   >
   >
     <template #sidebar
     <template #sidebar
-      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
+      ><div class="environment-entry"><button type="button" @click="environmentOpen=true">电脑与环境</button></div><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
     /></template>
     /></template>
     <template #mobile-sidebar
     <template #mobile-sidebar
-      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
+      ><div class="environment-entry"><button type="button" @click="environmentOpen=true">电脑与环境</button></div><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
     /></template>
     /></template>
     <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
     <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
       <MessageTimeline ref="timeline" :messages="uiMessages" :title="active?.name || 'Bot 模式'"
       <MessageTimeline ref="timeline" :messages="uiMessages" :title="active?.name || 'Bot 模式'"
@@ -688,6 +690,7 @@ onBeforeUnmount(() => {
         :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
         :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
         @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event" />
         @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event" />
     </section>
     </section>
+    <ComputerEnvironmentHub v-if="environmentOpen" @close="environmentOpen=false" @changed="refresh()" />
     <ComputerPanel v-if="computerOpen" :agents="computerAgents" @changed="load()" @close="computerOpen=false" />
     <ComputerPanel v-if="computerOpen" :agents="computerAgents" @changed="load()" @close="computerOpen=false" />
     <FloatingResourceSearch section="groups" label="搜索聊天" :items="searchItems" tabbed @select="select" />
     <FloatingResourceSearch section="groups" label="搜索聊天" :items="searchItems" tabbed @select="select" />
     <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
     <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
@@ -829,6 +832,7 @@ onBeforeUnmount(() => {
   </WorkspaceShell>
   </WorkspaceShell>
 </template>
 </template>
 <style scoped>
 <style scoped>
+.environment-entry{padding:8px 12px}.environment-entry button{width:100%;min-height:44px;text-align:left;padding:8px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.environment-entry button:focus-visible{outline:2px solid var(--accent)}
 .workspace-chat{display:flex;min-width:0;min-height:0;flex:1;flex-direction:column}.new-actions{display:grid;gap:2px}.new-actions button{display:flex;align-items:center;min-height:40px;padding:0 11px;border:0;border-radius:9px;background:transparent;color:var(--text-primary);text-align:left;font-size:12px;font-weight:610;cursor:pointer}.new-actions button:hover{background:var(--surface-hover)}.header-actions{display:flex;gap:4px;margin-left:auto}.error{display:flex;align-items:center;justify-content:space-between;margin:0 18px 9px;color:var(--danger);font-size:12px}.team-avatar-settings{display:grid;gap:12px}.team-avatar-options{display:flex;gap:10px}.team-avatar-options button{padding:5px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft);cursor:pointer}.team-avatar-options button[aria-pressed=true]{border-color:var(--accent)}.nodes-overlay{position:fixed;inset:0;z-index:1000;background:var(--scrim);display:grid;place-items:center}.nodes-overlay>div{padding:20px;max-height:85vh;overflow:auto;width:min(520px,90vw);background:var(--surface-raised);border:1px solid var(--line);border-radius:16px}
 .workspace-chat{display:flex;min-width:0;min-height:0;flex:1;flex-direction:column}.new-actions{display:grid;gap:2px}.new-actions button{display:flex;align-items:center;min-height:40px;padding:0 11px;border:0;border-radius:9px;background:transparent;color:var(--text-primary);text-align:left;font-size:12px;font-weight:610;cursor:pointer}.new-actions button:hover{background:var(--surface-hover)}.header-actions{display:flex;gap:4px;margin-left:auto}.error{display:flex;align-items:center;justify-content:space-between;margin:0 18px 9px;color:var(--danger);font-size:12px}.team-avatar-settings{display:grid;gap:12px}.team-avatar-options{display:flex;gap:10px}.team-avatar-options button{padding:5px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft);cursor:pointer}.team-avatar-options button[aria-pressed=true]{border-color:var(--accent)}.nodes-overlay{position:fixed;inset:0;z-index:1000;background:var(--scrim);display:grid;place-items:center}.nodes-overlay>div{padding:20px;max-height:85vh;overflow:auto;width:min(520px,90vw);background:var(--surface-raised);border:1px solid var(--line);border-radius:16px}
 .editor {
 .editor {
   border: 1px solid var(--line);
   border: 1px solid var(--line);

+ 18 - 3
src/runner/agent.ts

@@ -2,6 +2,7 @@ import { randomUUID, createHash } from 'node:crypto'
 import { DatabaseSync } from 'node:sqlite'
 import { DatabaseSync } from 'node:sqlite'
 import { realpath,access } from 'node:fs/promises'
 import { realpath,access } from 'node:fs/promises'
 import { resolve, sep, join } from 'node:path'
 import { resolve, sep, join } from 'node:path'
+import {ImageManagement} from './computers/imageManagement.js'
 import {ComputerError} from './computers/container.js'
 import {ComputerError} from './computers/container.js'
 import {z} from 'zod'
 import {z} from 'zod'
 import {ComputerRuntime,ComputerGateway,type ComputerTarget} from './worker/gateway.js'
 import {ComputerRuntime,ComputerGateway,type ComputerTarget} from './worker/gateway.js'
@@ -10,6 +11,7 @@ import { UpstreamClient } from '../server/upstream.js'
 import { UpstreamServiceSession } from '../server/localAuth.js'
 import { UpstreamServiceSession } from '../server/localAuth.js'
 import { WorkspaceGateway, type GatewayTarget, type GatewayFrame } from '../server/workspaceGateway.js'
 import { WorkspaceGateway, type GatewayTarget, type GatewayFrame } from '../server/workspaceGateway.js'
 import { createWorkspaceToolLease, type WorkspaceToolLease } from '../server/workspaceToolLease.js'
 import { createWorkspaceToolLease, type WorkspaceToolLease } from '../server/workspaceToolLease.js'
+import {UNCONFIGURED_COMPUTER_IMAGE} from '../shared/runner.js'
 import type { RunnerCommand, RunnerConfiguration } from '../shared/runner.js'
 import type { RunnerCommand, RunnerConfiguration } from '../shared/runner.js'
 import { LoopbackTransport, isLocalAuthorizationTarget } from '../server/loopbackAuthorization.js'
 import { LoopbackTransport, isLocalAuthorizationTarget } from '../server/loopbackAuthorization.js'
 
 
@@ -30,6 +32,7 @@ export class RunnerAgent {
   private inflight=new Map<string,Promise<unknown>>()
   private inflight=new Map<string,Promise<unknown>>()
   private db:DatabaseSync
   private db:DatabaseSync
   private computers?:ComputerRuntime
   private computers?:ComputerRuntime
+  private imageManagement?:ImageManagement
   private target:GatewayTarget
   private target:GatewayTarget
   private controlTransport=new LoopbackTransport()
   private controlTransport=new LoopbackTransport()
   private serverEpoch?:string
   private serverEpoch?:string
@@ -43,7 +46,7 @@ export class RunnerAgent {
     this.target={url:hermes,client,session:new UpstreamServiceSession(client,()=>config.hermesCredentials)}
     this.target={url:hermes,client,session:new UpstreamServiceSession(client,()=>config.hermesCredentials)}
     this.db=new DatabaseSync(join(home,'runner-commands.sqlite3'))
     this.db=new DatabaseSync(join(home,'runner-commands.sqlite3'))
     this.db.exec('CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,state TEXT NOT NULL,result TEXT,created INTEGER NOT NULL)')
     this.db.exec('CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,state TEXT NOT NULL,result TEXT,created INTEGER NOT NULL)')
-    if(config.computers)this.computers=new ComputerRuntime(this.db,config,home)
+    if(config.computers){this.computers=new ComputerRuntime(this.db,config,home);this.imageManagement=new ImageManagement(this.computers,this.db,home)}
     this.compactReceipts()
     this.compactReceipts()
   }
   }
   private async api(path:string,body?:unknown):Promise<any> {
   private async api(path:string,body?:unknown):Promise<any> {
@@ -51,7 +54,7 @@ export class RunnerAgent {
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(25000)]),
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(25000)]),
-      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'computer-worker-v1,artifact-chunks-v1,helper-retirement-v1,computer-control-v1,shared-computer-v1':'',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
+      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'computer-worker-v1,artifact-chunks-v1,helper-retirement-v1,computer-control-v1,shared-computer-v1,image-management-v1'+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     })
     if(!response.ok)throw new HttpError(response.status,`执行节点连接被拒绝(HTTP ${response.status})`,response.status===403?'runner_unauthorized':'runner_request_failed')
     if(!response.ok)throw new HttpError(response.status,`执行节点连接被拒绝(HTTP ${response.status})`,response.status===403?'runner_unauthorized':'runner_request_failed')
@@ -87,6 +90,18 @@ export class RunnerAgent {
   private async execute(command:RunnerCommand):Promise<any> {
   private async execute(command:RunnerCommand):Promise<any> {
     const p=command.payload
     const p=command.payload
     if(!this.active||!this.connected)throw new Error('执行节点已断开')
     if(!this.active||!this.connected)throw new Error('执行节点已断开')
+    if(command.kind==='images.manage'){
+      if(!this.imageManagement)throw new HttpError(409,'执行节点尚未启用隔离电脑,请更新节点配置','computer_unavailable')
+      const input=z.object({owner:z.string().regex(/^[a-f0-9]{64}$/),op:z.enum(['status','start','upload','append','cancel-upload','download','remove-export']),id:z.string().uuid().optional(),operation:z.enum(['prepare','verify','activate','rollback','import','export']).optional(),imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(),uploadId:z.string().uuid().optional(),manifest:z.unknown().optional(),size:z.number().optional(),offset:z.number().optional(),data:z.string().max(750000).optional()}).strict().parse(p)
+      const check=async()=>{if(!this.connected||!this.active||(await this.api('image-check',{id:input.id})).allowed!==true)throw new ComputerError('image_authorization_revoked','镜像管理授权已失效')}
+      if(input.op==='status')return this.imageManagement.inventory(input.owner)
+      if(input.op==='start')return this.imageManagement.start(input.owner,input.id!,input.operation!,{...(input.imageId?{imageId:input.imageId}:{}),...(input.uploadId?{uploadId:input.uploadId}:{})},check)
+      if(input.op==='upload')return this.imageManagement.beginUpload(input.owner,input.id!,input.size!,input.manifest)
+      if(input.op==='append')return this.imageManagement.appendUpload(input.owner,input.id!,input.offset!,input.data!)
+      if(input.op==='cancel-upload')return this.imageManagement.cancelUpload(input.owner,input.id!)
+      if(input.op==='remove-export')return this.imageManagement.removeExport(input.owner,input.id!)
+      return this.imageManagement.download(input.owner,input.id!,input.offset!)
+    }
     if(command.kind==='computer.control'){
     if(command.kind==='computer.control'){
       if(!this.computers)throw new HttpError(409,'电脑服务未配置','computer_unavailable')
       if(!this.computers)throw new HttpError(409,'电脑服务未配置','computer_unavailable')
       const input=z.object({target:z.object({environmentId:z.string().uuid(),agentId:z.string().uuid(),ownerKey:z.string().regex(/^[a-f0-9]{64}$/)}).strict(),profile:z.string(),op:z.enum(['status','frame','take','input','giveback']),controlId:z.string().uuid().optional(),requestId:z.string().uuid().optional(),generation:z.number().int().optional(),frameId:z.string().uuid().optional(),action:z.unknown().optional(),notes:z.string().max(4000).optional()}).strict().parse(p)
       const input=z.object({target:z.object({environmentId:z.string().uuid(),agentId:z.string().uuid(),ownerKey:z.string().regex(/^[a-f0-9]{64}$/)}).strict(),profile:z.string(),op:z.enum(['status','frame','take','input','giveback']),controlId:z.string().uuid().optional(),requestId:z.string().uuid().optional(),generation:z.number().int().optional(),frameId:z.string().uuid().optional(),action:z.unknown().optional(),notes:z.string().max(4000).optional()}).strict().parse(p)
@@ -264,7 +279,7 @@ export class RunnerAgent {
       try{
       try{
         await this.disconnect().catch(()=>{})
         await this.disconnect().catch(()=>{})
         await Promise.allSettled(deliveries.values());await Promise.allSettled(this.inflight.values())
         await Promise.allSettled(deliveries.values());await Promise.allSettled(this.inflight.values())
-        await this.computers?.controls.close();await this.computers?.pool.close()
+        await this.imageManagement?.close();await this.computers?.controls.close();await this.computers?.pool.close()
       }finally{this.target.client.close();this.controlTransport.close();this.db.close();this.status('执行节点已停止')}
       }finally{this.target.client.close();this.controlTransport.close();this.db.close();this.status('执行节点已停止')}
     }
     }
   }
   }

+ 142 - 0
src/runner/computers/imageManagement.ts

@@ -0,0 +1,142 @@
+import {mkdir,open,rm,stat,writeFile} from 'node:fs/promises'
+import {join,dirname,resolve} from 'node:path'
+import {existsSync} from 'node:fs'
+import {createHash} from 'node:crypto'
+import type {DatabaseSync} from 'node:sqlite'
+import {z} from 'zod'
+import {ComputerImages,imageManifest} from './images.js'
+import {ComputerError} from './container.js'
+import type {ComputerRuntime} from '../worker/gateway.js'
+import type {ImageJob,ImageInventory,ImageOperation} from '../../shared/imageManagement.js'
+const chunkSize=512*1024,maxSize=20*1024**3
+const digest=(v:unknown)=>createHash('sha256').update(JSON.stringify(v)).digest('hex')
+interface Upload {id:string;owner:string;size:number;offset:number;manifest:Record<string,unknown>;createdAt:number}
+interface StoredJob extends ImageJob {owner:string;fingerprint:string}
+/** Only administrator RPCs reach this manager; no host paths come from a model or browser. */
+export class ImageManagement {
+  private pending?:Promise<void>
+  private closed=false
+  private blocked?:{ids:string[];release:()=>void}
+  private repair?:Promise<void>
+  private directory:string
+  readonly images:ComputerImages
+  readonly ready:Promise<void>
+  constructor(readonly runtime:ComputerRuntime,readonly db:DatabaseSync,readonly home:string){
+    this.directory=join(home,'image-management');this.images=new ComputerImages(runtime.config.runtime)
+    db.exec('CREATE TABLE IF NOT EXISTS managed_image_jobs(id TEXT PRIMARY KEY,value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS managed_image_uploads(id TEXT PRIMARY KEY,value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS managed_image_selection(id INTEGER PRIMARY KEY,value TEXT NOT NULL)')
+    const selected=this.selection();if(selected?.base===runtime.config.imageId)runtime.config.imageId=selected.active;else if(selected)db.prepare('DELETE FROM managed_image_selection').run()
+    const release=runtime.pool.holdMaintenance();this.ready=runtime.ready.then(()=>this.recover()).then(()=>{if(this.blocked)this.blocked.release=release;else release()});void this.ready.catch(()=>{})
+  }
+  private selection():{base:string;active:string;previous?:string}|undefined{const row=this.db.prepare('SELECT value FROM managed_image_selection WHERE id=1').get() as any;return row?JSON.parse(row.value):undefined}
+  private job(id:string):StoredJob|undefined {const row=this.db.prepare('SELECT value FROM managed_image_jobs WHERE id=?').get(id) as any;return row?JSON.parse(row.value):undefined}
+  private jobs():StoredJob[]{return (this.db.prepare('SELECT value FROM managed_image_jobs ORDER BY rowid DESC LIMIT 40').all() as any[]).map(row=>JSON.parse(row.value))}
+  private save(job:StoredJob){job.updatedAt=Date.now();this.db.prepare('INSERT OR REPLACE INTO managed_image_jobs VALUES(?,?)').run(job.id,JSON.stringify(job))}
+  private async recover(){
+    await mkdir(this.directory,{recursive:true,mode:0o700})
+    for(const job of this.jobs())if(job.state==='running'||job.state==='uncertain'){
+      job.state='uncertain';job.message='节点曾重启,原操作不会重复执行。请刷新列表或重新校验镜像。';this.save(job)
+      try{await this.cleanupCheck(job.id)}catch{this.blocked??={ids:[],release:()=>{}};this.blocked.ids.push(job.id);job.message='测试电脑停止尚未确认,刷新状态重试清理';this.save(job)}
+    }
+    for(const row of this.db.prepare('SELECT value FROM managed_image_uploads').all() as any[]){const u:Upload=JSON.parse(row.value);if(Date.now()-u.createdAt>2*3600000){await rm(this.uploadPath(u.id),{force:true});await rm(this.uploadPath(u.id)+'.json',{force:true});this.db.prepare('DELETE FROM managed_image_uploads WHERE id=?').run(u.id)}}
+  }
+  private checkSpec(id:string,imageId=this.runtime.config.imageId){return {id,ownerKey:'image-management',imageId}}
+  private async cleanupCheck(id:string){const spec=this.checkSpec(id);await this.runtime.provider.remove(spec);await this.runtime.provider.deleteWorkspace(spec)}
+  private async retryCleanup(){
+    if(this.repair)return this.repair
+    const blocked=this.blocked;if(!blocked)return
+    this.repair=(async()=>{for(const id of blocked.ids)await this.cleanupCheck(id);if(this.blocked===blocked){this.blocked=undefined;blocked.release();for(const id of blocked.ids){const job=this.job(id);if(job){job.state='failed';job.message='测试电脑已清理,可重新校验镜像';this.save(job)}}}})().finally(()=>{this.repair=undefined})
+    return this.repair
+  }
+  async inventory(owner:string):Promise<ImageInventory>{
+    await this.ready;await this.retryCleanup().catch(()=>{})
+    let output:string
+    try{output=await this.images.run(this.images.runtime,['image','ls','--no-trunc','--quiet','--filter','label=com.openmausbot.cua-driver=0.20.0'])}catch{throw new ComputerError('image_runtime_unavailable','无法读取镜像,请确认执行节点上的 Docker/Podman 服务已启动')}
+    const images=[]
+    for(const id of [...new Set(output.trim().split(/\s+/).filter(Boolean))].slice(0,100))try{images.push(await this.images.inspect(id))}catch{}
+    return {configured:true,activeImageId:this.runtime.config.imageId,previousImageId:this.selection()?.previous,images,jobs:this.jobs().filter(j=>j.owner===owner).map(({owner:_o,fingerprint:_f,...j})=>j),busy:!!this.pending||!!this.blocked,uploads:(this.db.prepare('SELECT value FROM managed_image_uploads').all() as any[]).map(row=>JSON.parse(row.value) as Upload).filter(u=>u.owner===owner).map(u=>({id:u.id,size:u.size,offset:u.offset}))}
+  }
+  private uploadPath(id:string){return join(this.directory,id+'.tar')}
+  async beginUpload(owner:string,id:string,size:number,manifest:unknown){
+    z.string().uuid().parse(id);z.number().int().positive().max(maxSize).parse(size);const value=imageManifest.parse(manifest)
+    if(!value.archiveSha256)throw new ComputerError('image_manifest_invalid','请选择包含 SHA-256 的镜像校验清单')
+    await this.ready
+    const old=this.db.prepare('SELECT value FROM managed_image_uploads WHERE id=?').get(id) as any
+    if(old){const entry:Upload=JSON.parse(old.value);if(entry.owner!==owner||entry.size!==size||digest(entry.manifest)!==digest(value))throw new ComputerError('image_upload_conflict','上传编号已用于其他文件');return {id,offset:entry.offset}}
+    if(Number(this.db.prepare('SELECT count(*) AS n FROM managed_image_uploads').get()!.n)>=2)throw new ComputerError('image_upload_limit','请先完成或取消已有上传')
+    await writeFile(this.uploadPath(id),'',{flag:'wx',mode:0o600})
+    const entry:Upload={id,owner,size,manifest:value,offset:0,createdAt:Date.now()};this.db.prepare('INSERT INTO managed_image_uploads VALUES(?,?)').run(id,JSON.stringify(entry));return {id,offset:0}
+  }
+  private upload(owner:string,id:string):Upload{z.string().uuid().parse(id);const row=this.db.prepare('SELECT value FROM managed_image_uploads WHERE id=?').get(id) as any;const u:Upload|undefined=row?JSON.parse(row.value):undefined;if(!u||u.owner!==owner)throw new ComputerError('image_upload_missing','上传不存在或不属于当前账号');return u}
+  private uploading=new Set<string>()
+  async appendUpload(owner:string,id:string,offset:number,data:string){
+    const u=this.upload(owner,id);if(this.uploading.has(id))throw new ComputerError('image_upload_busy','上一块仍在写入')
+    const bytes=Buffer.from(z.string().max(750000).parse(data),'base64');z.number().int().nonnegative().parse(offset)
+    if(!bytes.length||bytes.length>chunkSize||offset+bytes.length>u.size)throw new ComputerError('image_upload_invalid','镜像数据块无效')
+    this.uploading.add(id)
+    try{
+      const file=await open(this.uploadPath(id),'r+')
+      try{
+        if(offset<u.offset){const old=Buffer.alloc(bytes.length);await file.read(old,0,old.length,offset);if(offset+bytes.length>u.offset||!old.equals(bytes))throw new Error('上传内容冲突');return {offset:u.offset}}
+        if(offset!==u.offset)throw new ComputerError('image_upload_offset','请从已接收的位置继续上传')
+        let written=0;while(written<bytes.length){const result=await file.write(bytes,written,bytes.length-written,offset+written);if(!result.bytesWritten)throw new Error('写入未完成');written+=result.bytesWritten}await file.sync();u.offset+=bytes.length
+        this.db.prepare('UPDATE managed_image_uploads SET value=? WHERE id=?').run(JSON.stringify(u),id);return {offset:u.offset}
+      }finally{await file.close()}
+    }finally{this.uploading.delete(id)}
+  }
+  async cancelUpload(owner:string,id:string){this.upload(owner,id);if(this.uploading.has(id)||this.pending)throw new ComputerError('image_upload_busy','请等待当前操作结束');await rm(this.uploadPath(id),{force:true});this.db.prepare('DELETE FROM managed_image_uploads WHERE id=?').run(id);return {ok:true}}
+  async start(owner:string,id:string,operation:ImageOperation,args:{imageId?:string;uploadId?:string},check:()=>Promise<void>){
+    await this.ready;await check();z.string().uuid().parse(id)
+    const fingerprint=digest({owner,operation,args}),old=this.job(id)
+    if(old){if(old.fingerprint!==fingerprint)throw new ComputerError('idempotency_conflict','操作编号与内容不一致');return {job:old}}
+    if(this.closed||this.pending||this.blocked)throw new ComputerError('image_job_busy','已有镜像操作正在执行')
+    z.enum(['prepare','verify','activate','rollback','import','export']).parse(operation)
+    if(operation==='export'&&this.jobs().filter(j=>j.archiveSize).length>=3)throw new ComputerError('image_export_limit','请先清理之前的导出文件,最多保留三份')
+    const release=this.runtime.pool.beginMaintenance()
+    const job:StoredJob={id,owner,operation,fingerprint,state:'running',message:'正在准备',createdAt:Date.now(),updatedAt:Date.now()};this.save(job)
+    const progress=(message:string)=>{job.message=message;this.save(job)}
+    this.pending=(async()=>{
+      try{
+        await this.runtime.provider.verifyRuntime();await check()
+        const manager=this.images
+        const verify=async(manifest:any)=>{
+          progress('正在启动测试电脑并校验桌面');await check()
+          const spec=this.checkSpec(id,manifest.imageId)
+          try{await this.runtime.provider.ensure(spec,()=>{if(this.closed)throw new Error('节点正在关闭')});const end=Date.now()+60000;for(;;){await check();try{await this.runtime.provider.health(spec,()=>{});await this.runtime.provider.capture(spec,()=>{});break}catch(e){if(Date.now()>end)throw e;await new Promise(r=>setTimeout(r,1000))}}}
+          finally{try{await this.runtime.provider.remove(spec);await this.runtime.provider.deleteWorkspace(spec)}catch{this.blocked={ids:[id],release};throw new ComputerError('computer_stop_uncertain','测试电脑停止尚未确认,请刷新状态重试清理')}}
+        }
+        const previousVerify=manager.verify.bind(manager);manager.verify=verify
+        try{
+          let manifest:any
+          if(operation==='prepare'){progress('正在构建兼容镜像,首次准备可能需要数分钟');manifest=await manager.prepare([join(dirname(this.runtime.script),'computer-image'),join(dirname(this.runtime.script),'../computers/computer-image'),resolve('deploy/computer')].find(path=>existsSync(join(path,'Dockerfile')))??'missing-computer-recipe')}
+          else if(operation==='import'){
+            const u=this.upload(owner,args.uploadId!);if(u.offset!==u.size)throw new ComputerError('image_upload_incomplete','镜像尚未上传完成')
+            progress('正在校验归档并导入镜像');await writeFile(this.uploadPath(u.id)+'.json',JSON.stringify(u.manifest),{mode:0o600})
+            manifest=await manager.import(this.uploadPath(u.id));await rm(this.uploadPath(u.id),{force:true});await rm(this.uploadPath(u.id)+'.json',{force:true});this.db.prepare('DELETE FROM managed_image_uploads WHERE id=?').run(u.id)
+          }else{
+            const selected=operation==='rollback'?this.selection()?.previous:args.imageId
+            if(!selected)throw new ComputerError('image_missing','没有可用的镜像或回退版本')
+            manifest=await manager.inspect(selected)
+            if(operation==='export'){progress('正在导出镜像');manifest=await manager.export(selected,join(this.directory,id+'.export.tar'));job.archiveSize=(await stat(join(this.directory,id+'.export.tar'))).size;job.manifest=manifest}
+            else await verify(manifest)
+          }
+          await check();job.imageId=manifest.imageId
+          if((operation==='activate'||operation==='rollback')&&manifest.imageId!==this.runtime.config.imageId){
+            const old=this.selection();this.db.prepare('INSERT OR REPLACE INTO managed_image_selection VALUES(1,?)').run(JSON.stringify({base:old?.base??this.runtime.config.imageId,active:manifest.imageId,previous:this.runtime.config.imageId}))
+            this.runtime.config.imageId=manifest.imageId
+          }
+          job.state='complete';progress(operation==='activate'||operation==='rollback'?'已应用,下次启动电脑使用此镜像':'操作完成')
+        }finally{manager.verify=previousVerify}
+      }catch(error){job.state=this.blocked?'uncertain':'failed';progress(error instanceof Error&&/^(镜像|电脑|上传|请|没有)/.test(error.message)?error.message.slice(0,240):'镜像操作失败,请检查运行时、网络和镜像清单后重试')}
+      finally{if(!this.blocked)release();this.pending=undefined}
+    })()
+    return {job:{...job}}
+  }
+  async download(owner:string,id:string,offset:number){
+    z.string().uuid().parse(id);z.number().int().nonnegative().parse(offset)
+    const job=this.job(id)
+    if(!job||job.owner!==owner||job.state!=='complete'||job.operation!=='export'||!job.archiveSize||offset>job.archiveSize)throw new ComputerError('image_export_missing','导出文件不存在')
+    const file=await open(join(this.directory,id+'.export.tar'),'r');try{const bytes=Buffer.alloc(Math.min(chunkSize,job.archiveSize-offset));let received=0;while(received<bytes.length){const result=await file.read(bytes,received,bytes.length-received,offset+received);if(!result.bytesRead)throw new ComputerError('image_export_incomplete','镜像导出不完整');received+=result.bytesRead}return {data:bytes.toString('base64'),size:job.archiveSize,offset,manifest:job.manifest}}finally{await file.close()}
+  }
+  async removeExport(owner:string,id:string){const job=this.job(id);if(!job||job.owner!==owner||job.state!=='complete'||job.operation!=='export')throw new ComputerError('image_export_missing','导出不存在');await rm(join(this.directory,id+'.export.tar'),{force:true});await rm(join(this.directory,id+'.export.tar.json'),{force:true});delete job.archiveSize;job.message='导出文件已清理,可重新导出';this.save(job);return {ok:true}}
+  async close(){this.closed=true;await this.ready;await this.pending;await this.retryCleanup()}
+}

+ 9 - 1
src/runner/computers/pool.ts

@@ -15,6 +15,12 @@ export class ComputerPool {
   private changing=new Map<string,Promise<unknown>>()
   private changing=new Map<string,Promise<unknown>>()
   private ready=false
   private ready=false
   private closing=false
   private closing=false
+  private maintenance=false
+  holdMaintenance(){this.maintenance=true;let released=false;return ()=>{if(!released){released=true;this.maintenance=false}}}
+  beginMaintenance(){
+    if(this.maintenance||this.changing.size||this.rows().some(row=>row.status!=='free'))throw new ComputerError('computer_busy','请先停止电脑任务并交还控制权,再管理镜像')
+    return this.holdMaintenance()
+  }
   constructor(readonly db:DatabaseSync,readonly provider:ComputerProvider,readonly limits={concurrent:2,environments:32,ttlMs:30000},readonly now=Date.now) {
   constructor(readonly db:DatabaseSync,readonly provider:ComputerProvider,readonly limits={concurrent:2,environments:32,ttlMs:30000},readonly now=Date.now) {
     if(![limits.concurrent,limits.environments,limits.ttlMs].every(Number.isSafeInteger)||limits.concurrent<1||limits.concurrent>8||limits.environments<limits.concurrent||limits.environments>128||limits.ttlMs<1000||limits.ttlMs>120000)throw new Error('电脑资源配额无效')
     if(![limits.concurrent,limits.environments,limits.ttlMs].every(Number.isSafeInteger)||limits.concurrent<1||limits.concurrent>8||limits.environments<limits.concurrent||limits.environments>128||limits.ttlMs<1000||limits.ttlMs>120000)throw new Error('电脑资源配额无效')
     db.exec('CREATE TABLE IF NOT EXISTS computer_environments(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
     db.exec('CREATE TABLE IF NOT EXISTS computer_environments(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
@@ -49,6 +55,7 @@ export class ComputerPool {
     if(!holderId||holderId.length>256)throw new ComputerError('computer_holder_invalid','电脑任务身份无效')
     if(!holderId||holderId.length>256)throw new ComputerError('computer_holder_invalid','电脑任务身份无效')
     return this.serial(spec.id,async()=>{
     return this.serial(spec.id,async()=>{
       authorize()
       authorize()
+      if(this.maintenance)throw new ComputerError('computer_busy','执行节点正在管理镜像,请稍后重试')
       if(!this.ready||this.closing)throw new ComputerError('computer_pool_unavailable','电脑资源正在恢复或关闭')
       if(!this.ready||this.closing)throw new ComputerError('computer_pool_unavailable','电脑资源正在恢复或关闭')
       let entry=this.get(spec.id)
       let entry=this.get(spec.id)
       if(entry&&(entry.spec.ownerKey!==spec.ownerKey||JSON.stringify(entry.spec)!==JSON.stringify(spec)))throw new ComputerError('computer_owner_mismatch','电脑环境归属或配置不匹配')
       if(entry&&(entry.spec.ownerKey!==spec.ownerKey||JSON.stringify(entry.spec)!==JSON.stringify(spec)))throw new ComputerError('computer_owner_mismatch','电脑环境归属或配置不匹配')
@@ -73,7 +80,8 @@ export class ComputerPool {
   async configure(spec:ComputerSpecification,authorize:()=>void):Promise<void>{
   async configure(spec:ComputerSpecification,authorize:()=>void):Promise<void>{
     spec=this.provider.validateSpecification(spec)
     spec=this.provider.validateSpecification(spec)
     return this.serial(spec.id,async()=>{
     return this.serial(spec.id,async()=>{
-      authorize();if(!this.ready||this.closing)throw new ComputerError('computer_pool_unavailable','电脑资源正在恢复或关闭')
+      authorize();if(this.maintenance)throw new ComputerError('computer_busy','执行节点正在管理镜像,请稍后重试')
+      if(!this.ready||this.closing)throw new ComputerError('computer_pool_unavailable','电脑资源正在恢复或关闭')
       const entry=this.get(spec.id);if(!entry)return
       const entry=this.get(spec.id);if(!entry)return
       if(entry.spec.ownerKey!==spec.ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
       if(entry.spec.ownerKey!==spec.ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
       if(JSON.stringify(entry.spec)===JSON.stringify(spec))return
       if(JSON.stringify(entry.spec)===JSON.stringify(spec))return

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

@@ -7,6 +7,7 @@ import type {DatabaseSync} from 'node:sqlite'
 import {z} from 'zod'
 import {z} from 'zod'
 import {HttpError} from '../../server/errors.js'
 import {HttpError} from '../../server/errors.js'
 import type {GatewayFrame} from '../../server/workspaceGateway.js'
 import type {GatewayFrame} from '../../server/workspaceGateway.js'
+import {UNCONFIGURED_COMPUTER_IMAGE} from '../../shared/runner.js'
 import type {RunnerConfiguration} from '../../shared/runner.js'
 import type {RunnerConfiguration} from '../../shared/runner.js'
 import {ContainerComputerProvider,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
 import {ContainerComputerProvider,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
 import {ComputerPool,type ComputerLease} from '../computers/pool.js'
 import {ComputerPool,type ComputerLease} from '../computers/pool.js'
@@ -105,6 +106,7 @@ export class ComputerGateway {
       if(this.live)throw new HttpError(409,'电脑通道已有会话','computer_session_busy')
       if(this.live)throw new HttpError(409,'电脑通道已有会话','computer_session_busy')
       await this.authorized()
       await this.authorized()
       if(method==='session.resume'&&params.recoverOnly===true){const session=this.runtime.session(String(params.session_id),this.meta,String(params.profile));this.recoverySession=session;const resource=this.runtime.pool.status(this.meta.ownerKey).find(item=>item.environmentId===this.meta.environmentId);if(resource?.holderId===this.workId)await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:session.profile}}}
       if(method==='session.resume'&&params.recoverOnly===true){const session=this.runtime.session(String(params.session_id),this.meta,String(params.profile));this.recoverySession=session;const resource=this.runtime.pool.status(this.meta.ownerKey).find(item=>item.environmentId===this.meta.environmentId);if(resource?.holderId===this.workId)await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:session.profile}}}
+      if(this.runtime.config.imageId===UNCONFIGURED_COMPUTER_IMAGE)throw new HttpError(409,'请先在电脑与环境的镜像页面准备并应用镜像','computer_image_required')
       const profile=String(params.profile),resolved=await this.runtime.resolve(profile,this.controller.signal)
       const profile=String(params.profile),resolved=await this.runtime.resolve(profile,this.controller.signal)
       await this.authorized();this.guard()
       await this.authorized();this.guard()
       const session:WorkerSession=method==='session.resume'?this.runtime.session(String(params.session_id),this.meta,profile):{id:randomUUID(),agentId:this.meta.agentId,ownerKey:this.meta.ownerKey,environmentId:this.meta.environmentId,profile,cwd:resolved.cwd,configuredCwd:resolved.configuredCwd,history:[]}
       const session:WorkerSession=method==='session.resume'?this.runtime.session(String(params.session_id),this.meta,profile):{id:randomUUID(),agentId:this.meta.agentId,ownerKey:this.meta.ownerKey,environmentId:this.meta.environmentId,profile,cwd:resolved.cwd,configuredCwd:resolved.configuredCwd,history:[]}

+ 5 - 1
src/server/app.ts

@@ -49,6 +49,7 @@ import {
   HermesChatNotificationResolver,
   HermesChatNotificationResolver,
 } from './pushEvents.js'
 } from './pushEvents.js'
 import { ChatCacheCoordinator, ChatCacheStore } from './chatCache.js'
 import { ChatCacheCoordinator, ChatCacheStore } from './chatCache.js'
+import {ImageManagementService} from './imageManagement.js'
 import {SharedComputers} from './sharedComputers.js'
 import {SharedComputers} from './sharedComputers.js'
 import {ComputerControlService} from './computerControls.js'
 import {ComputerControlService} from './computerControls.js'
 import { RunnerHub } from './runnerHub.js'
 import { RunnerHub } from './runnerHub.js'
@@ -212,6 +213,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
   const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
   const sharedComputers=new SharedComputers(workspace,auth,workspaceNodes,runners)
   const sharedComputers=new SharedComputers(workspace,auth,workspaceNodes,runners)
   const computerControls=new ComputerControlService(workspace,auth,workspaceNodes,runners)
   const computerControls=new ComputerControlService(workspace,auth,workspaceNodes,runners)
+  const imageManagement=new ImageManagementService(workspace,auth,runners)
+  runners.imageAllowed=(id,runnerId)=>imageManagement.allowed(id,runnerId)
   runners.controlAllowed=(id,runnerId)=>computerControls.allowed(id,runnerId)
   runners.controlAllowed=(id,runnerId)=>computerControls.allowed(id,runnerId)
   workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
   workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
   const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0)
   const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0)
@@ -309,7 +312,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
       const path = ctx.path
       const path = ctx.path
       const publicPath = ['/api/status', '/api/auth/providers', '/api/auth/me', '/api/profiles', '/api/app/bootstrap', '/api/app/login', '/api/app/logout'].includes(path)
       const publicPath = ['/api/status', '/api/auth/providers', '/api/auth/me', '/api/profiles', '/api/app/bootstrap', '/api/app/login', '/api/app/logout'].includes(path)
       const accountPath = ['/api/account/credentials', '/api/app/account/credentials', '/api/app/account/avatar'].includes(path)
       const accountPath = ['/api/account/credentials', '/api/app/account/credentials', '/api/app/account/avatar'].includes(path)
-      const workspacePath = /^\/api\/app\/(?:capabilities|agents|conversations|runs|interactions|events|uploads|files|message-files)(?:\/|$)/.test(path)
+      const workspacePath = /^\/api\/app\/(?:capabilities|agents|conversations|runs|interactions|events|uploads|files|message-files|computers)(?:\/|$)/.test(path)
         && path !== '/api/app/agents/remote'
         && path !== '/api/app/agents/remote'
       const pushPath = /^\/api\/(?:app\/)?push\/v1\//.test(path)
       const pushPath = /^\/api\/(?:app\/)?push\/v1\//.test(path)
       const identityRead = path === '/api/app/server-identity' && ['GET', 'HEAD'].includes(ctx.method)
       const identityRead = path === '/api/app/server-identity' && ['GET', 'HEAD'].includes(ctx.method)
@@ -378,6 +381,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   })
   })
 
 
   const sharedComputerRouter=sharedComputers.router();app.use(sharedComputerRouter.routes());app.use(sharedComputerRouter.allowedMethods())
   const sharedComputerRouter=sharedComputers.router();app.use(sharedComputerRouter.routes());app.use(sharedComputerRouter.allowedMethods())
+  const imageRouter=imageManagement.router();app.use(imageRouter.routes());app.use(imageRouter.allowedMethods())
   const computerRouter=computerControls.router();app.use(computerRouter.routes());app.use(computerRouter.allowedMethods())
   const computerRouter=computerControls.router();app.use(computerRouter.routes());app.use(computerRouter.allowedMethods())
   const applicationRouter = workspaceRouter(workspace, workspaceRuntime, workspaceNodes, workspaceAssets, uploads, auth, push)
   const applicationRouter = workspaceRouter(workspace, workspaceRuntime, workspaceNodes, workspaceAssets, uploads, auth, push)
   app.use(runners.adminRouter().routes())
   app.use(runners.adminRouter().routes())

+ 8 - 0
src/server/computerControls.ts

@@ -30,6 +30,14 @@ export class ComputerControlService {
   }
   }
   router(){
   router(){
     const router=new Router()
     const router=new Router()
+    router.get('/api/app/computers',ctx=>{
+      const actor=this.auth.require(ctx),agents=this.store.list<WorkspaceAgent>(actor.id,'agent').filter(agent=>!agent.archived)
+      ctx.body={canManageImages:actor.role==='admin',computers:agents.map(agent=>{
+        let available=false,reason='在 Agent 设置中将执行环境设为隔离电脑'
+        if(agent.execution==='computer')try{this.nodes.requireSource(actor.id,agent);const record=this.hub.computerRunner(actor.id,agent),features=this.hub.summary(record).features;if(features.includes('image-management-v1')&&!features.includes('image-ready-v1'))throw new Error('请先在镜像页面准备并应用镜像');available=true;reason='可以打开电脑面板'}catch(error){reason=error instanceof Error?error.message:'电脑不可用'}
+        return {agent,available,reason}
+      })}
+    })
     router.get('/api/app/agents/:id/computer',async ctx=>{
     router.get('/api/app/agents/:id/computer',async ctx=>{
       const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id)
       const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id)
       ctx.body=await this.hub.computer(owner,agent,'status',{},()=>{this.agent(owner,agent.id)})
       ctx.body=await this.hub.computer(owner,agent,'status',{},()=>{this.agent(owner,agent.id)})

+ 37 - 0
src/server/imageManagement.ts

@@ -0,0 +1,37 @@
+import Router from '@koa/router'
+import {Readable} from 'node:stream'
+import {z} from 'zod'
+import type {WorkspaceStore} from './workspaceStore.js'
+import {parse} from './workspaceStore.js'
+import type {LocalAuthStore} from './localAuth.js'
+import type {RunnerHub} from './runnerHub.js'
+import {HttpError} from './errors.js'
+interface Grant {id:string;owner:string;runnerId:string;version:number;expiresAt:number}
+export class ImageManagementService {
+  constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly hub:RunnerHub){}
+  allowed(id:string,runnerId:string){const grant=this.store.get<Grant>('_system','image-management-grant',id);return !!grant&&grant.runnerId===runnerId&&grant.expiresAt>Date.now()&&this.auth.isAdminActive(grant.owner)&&this.auth.pushAuthorizationVersion(grant.owner)===grant.version}
+  router(){
+    const router=new Router(),base='/api/app/admin/runners/:id/images'
+    const call=async(ctx:any,payload:Record<string,unknown>)=>{const owner=this.auth.requireAdmin(ctx).id;ctx.set('Cache-Control','no-store');return this.hub.images(owner,ctx.params.id,payload)}
+    router.get(base,async ctx=>{ctx.body=await call(ctx,{op:'status'})})
+    router.post(base+'/jobs',async ctx=>{
+      const actor=this.auth.requireAdmin(ctx),body=parse(z.object({requestId:z.string().uuid(),operation:z.enum(['prepare','verify','activate','rollback','import','export']),imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(),uploadId:z.string().uuid().optional()}).strict(),(ctx.request as any).body)
+      const old=this.store.get<Grant>('_system','image-management-grant',body.requestId)
+      if(old&&(old.owner!==actor.id||old.runnerId!==ctx.params.id))throw new HttpError(409,'操作编号已使用','idempotency_conflict')
+      if(!old)this.store.put('_system','image-management-grant',body.requestId,{id:body.requestId,owner:actor.id,runnerId:ctx.params.id,version:this.auth.pushAuthorizationVersion(actor.id)??0,expiresAt:Date.now()+3600000})
+      ctx.body=await call(ctx,{op:'start',id:body.requestId,operation:body.operation,...(body.imageId?{imageId:body.imageId}:{}),...(body.uploadId?{uploadId:body.uploadId}:{})});ctx.status=202
+    })
+    router.post(base+'/uploads',async ctx=>{const body=parse(z.object({id:z.string().uuid(),size:z.number().int().positive().max(20*1024**3),manifest:z.record(z.string(),z.unknown())}).strict(),(ctx.request as any).body);ctx.body=await call(ctx,{op:'upload',...body})})
+    router.post(base+'/uploads/:uploadId',async ctx=>{const body=parse(z.object({offset:z.number().int().nonnegative(),data:z.string().max(750000)}).strict(),(ctx.request as any).body);ctx.body=await call(ctx,{op:'append',id:ctx.params.uploadId,...body})})
+    router.delete(base+'/uploads/:uploadId',async ctx=>{ctx.body=await call(ctx,{op:'cancel-upload',id:ctx.params.uploadId})})
+    router.delete(base+'/jobs/:jobId/archive',async ctx=>{ctx.body=await call(ctx,{op:'remove-export',id:ctx.params.jobId})})
+    router.get(base+'/jobs/:jobId/chunk',async ctx=>{ctx.body=await call(ctx,{op:'download',id:ctx.params.jobId,offset:Number(ctx.query.offset??0)})})
+    router.get(base+'/jobs/:jobId/manifest',async ctx=>{const data=await call(ctx,{op:'download',id:ctx.params.jobId,offset:0});ctx.attachment('computer-image.tar.json');ctx.body=data.manifest})
+    router.get(base+'/jobs/:jobId/archive',async ctx=>{
+      const first=await call(ctx,{op:'download',id:ctx.params.jobId,offset:0});ctx.attachment('computer-image.tar');ctx.type='application/octet-stream';ctx.length=first.size
+      const stream=Readable.from((async function*(){let part=first,offset=0;while(offset<first.size&&!ctx.res.destroyed){const bytes=Buffer.from(part.data,'base64');if(!bytes.length||part.offset!==offset)throw new Error('镜像导出数据不完整');yield bytes;offset+=bytes.length;if(offset<first.size)part=await call(ctx,{op:'download',id:ctx.params.jobId,offset})}})())
+      ctx.res.once('close',()=>stream.destroy());ctx.body=stream
+    })
+    return router
+  }
+}

+ 2 - 0
src/server/localAuth.ts

@@ -247,6 +247,8 @@ export class LocalAuthStore {
     return Boolean(user && (user.role === 'admin' || (nodeId === 'local' && user.assignedProfiles?.includes(profile))))
     return Boolean(user && (user.role === 'admin' || (nodeId === 'local' && user.assignedProfiles?.includes(profile))))
   }
   }
 
 
+  isAdminActive(userID:string):boolean {return this.#users.some(user=>user.id===userID&&user.enabled&&user.role==='admin')}
+
   isUserActive(userID: string): boolean {
   isUserActive(userID: string): boolean {
     return this.#users.some(user => user.id === userID && user.enabled)
     return this.#users.some(user => user.id === userID && user.enabled)
   }
   }

+ 13 - 2
src/server/runnerHub.ts

@@ -34,6 +34,16 @@ export class RunnerHub {
       for(const [id,upload] of this.artifacts)if(!this.connections.has(upload.connectionId))this.artifacts.delete(id)
       for(const [id,upload] of this.artifacts)if(!this.connections.has(upload.connectionId))this.artifacts.delete(id)
     },5000);this.watchdog.unref()
     },5000);this.watchdog.unref()
   }
   }
+  imageAllowed:(id:string,runnerId:string)=>boolean=()=>false
+  async images(owner:string,id:string,payload:Record<string,unknown>){
+    const record=this.store.require<RunnerRecord>('_system','runner',id),version=this.auth.pushAuthorizationVersion(owner)
+    if(!record.enabled||!this.online.get(id)?.features.includes('image-management-v1'))throw new HttpError(409,'节点离线或尚不支持镜像管理,请更新并连接 Runner','image_management_unavailable')
+    const valid=()=>this.auth.isAdminActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version&&this.store.get<RunnerRecord>('_system','runner',id)?.enabled===true
+    if(!valid())throw new HttpError(403,'镜像管理需要管理员权限','admin_required')
+    const result=await this.request(id,'images.manage',{...payload,owner:hash(owner)},valid)
+    if(!valid())throw new HttpError(403,'镜像管理授权已失效','admin_required')
+    return result
+  }
   records():RunnerRecord[] {return this.store.list('_system','runner')}
   records():RunnerRecord[] {return this.store.list('_system','runner')}
   enroll(owner:string,value:unknown) {
   enroll(owner:string,value:unknown) {
     const body=parse(input,value)
     const body=parse(input,value)
@@ -170,7 +180,7 @@ export class RunnerHub {
   }
   }
   middleware():Koa.Middleware {
   middleware():Koa.Middleware {
     return async(ctx,next)=>{
     return async(ctx,next)=>{
-      const match=/^\/api\/runner\/v1\/([0-9a-f-]{36})\/(poll|admit|result|event|tool|check|artifact|control-check)$/.exec(ctx.path)
+      const match=/^\/api\/runner\/v1\/([0-9a-f-]{36})\/(poll|admit|result|event|tool|check|artifact|control-check|image-check)$/.exec(ctx.path)
       if(!match)return next()
       if(!match)return next()
       const record=this.store.get<RunnerRecord>('_system','runner',match[1]!),token=ctx.get('authorization').replace(/^Bearer /,'')
       const record=this.store.get<RunnerRecord>('_system','runner',match[1]!),token=ctx.get('authorization').replace(/^Bearer /,'')
       if(ctx.get('origin')||!record?.enabled||!ctx.get('authorization').startsWith('Bearer ')||!timingSafeEqual(Buffer.from(record.tokenHash),Buffer.from(hash(token))))
       if(ctx.get('origin')||!record?.enabled||!ctx.get('authorization').startsWith('Bearer ')||!timingSafeEqual(Buffer.from(record.tokenHash),Buffer.from(hash(token))))
@@ -187,7 +197,7 @@ export class RunnerHub {
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
       }
       }
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
-      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(match[2]==='poll')state.features=ctx.get('x-runner-features').split(',').filter(value=>['computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1'].includes(value));this.online.set(record.id,state)
+      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','image-management-v1','image-ready-v1'].includes(value));this.online.set(record.id,state)
       ctx.set('Cache-Control','no-store')
       ctx.set('Cache-Control','no-store')
       if(match[2]==='poll') {
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')
@@ -208,6 +218,7 @@ export class RunnerHub {
       let body:any
       let body:any
       try {body=JSON.parse(Buffer.concat(chunks).toString())}catch{throw new HttpError(400,'JSON 无效','invalid_json')}
       try {body=JSON.parse(Buffer.concat(chunks).toString())}catch{throw new HttpError(400,'JSON 无效','invalid_json')}
       if(!body||typeof body!=='object'||Array.isArray(body))throw new HttpError(400,'请求必须是对象','invalid_json')
       if(!body||typeof body!=='object'||Array.isArray(body))throw new HttpError(400,'请求必须是对象','invalid_json')
+      if(match[2]==='image-check'){ctx.body={allowed:typeof body.id==='string'&&this.imageAllowed(body.id,record.id)};return}
       if(match[2]==='control-check'){ctx.body={allowed:typeof body.controlId==='string'&&this.controlAllowed(body.controlId,record.id)};return}
       if(match[2]==='control-check'){ctx.body={allowed:typeof body.controlId==='string'&&this.controlAllowed(body.controlId,record.id)};return}
       if(match[2]==='artifact'){
       if(match[2]==='artifact'){
         const value=parse(z.object({connectionId:z.string().uuid(),id:z.string().uuid(),phase:z.enum(['begin','append','finish']),name:z.string().min(1).max(240).optional(),size:z.number().int().min(0).max(25*1024*1024).optional(),digest:z.string().regex(/^[a-f0-9]{64}$/).optional(),index:z.number().int().min(0).optional(),data:z.string().max(1024*1024).optional()}).strict(),body)
         const value=parse(z.object({connectionId:z.string().uuid(),id:z.string().uuid(),phase:z.enum(['begin','append','finish']),name:z.string().min(1).max(240).optional(),size:z.number().int().min(0).max(25*1024*1024).optional(),digest:z.string().regex(/^[a-f0-9]{64}$/).optional(),index:z.number().int().min(0).optional(),data:z.string().max(1024*1024).optional()}).strict(),body)

+ 1 - 1
src/server/workspaceRoutes.ts

@@ -67,7 +67,7 @@ export function workspaceRouter(
         'conversationTasks',
         'conversationTasks',
         'editableGroups',
         'editableGroups',
         'agentTeamManagement',
         'agentTeamManagement',
-        'computerControl','sharedComputers',
+        'computerControl','sharedComputers','computerManagement',
         'files',
         'files',
         'voice',
         'voice',
         'context',
         'context',

+ 3 - 0
src/shared/imageManagement.ts

@@ -0,0 +1,3 @@
+export type ImageOperation='prepare'|'verify'|'activate'|'rollback'|'import'|'export'
+export interface ImageJob {id:string;operation:ImageOperation;state:'running'|'complete'|'failed'|'uncertain';message:string;imageId?:string;createdAt:number;updatedAt:number;archiveSize?:number;manifest?:Record<string,unknown>}
+export interface ImageInventory {configured:boolean;activeImageId?:string;previousImageId?:string;images:Array<{imageId:string;architecture:string;driver:string}>;jobs:ImageJob[];busy:boolean;uploads?:Array<{id:string;size:number;offset:number}>}

+ 2 - 1
src/shared/runner.ts

@@ -11,7 +11,7 @@ export interface RunnerRecord {
 }
 }
 export interface RunnerCommand {
 export interface RunnerCommand {
   id: string
   id: string
-  kind: 'computer.control' | 'computer.retire' | 'http' | 'gateway.open' | 'gateway.rpc' | 'gateway.close' | 'lease.create' | 'lease.bind' | 'lease.close'
+  kind: 'images.manage' | 'computer.control' | 'computer.retire' | 'http' | 'gateway.open' | 'gateway.rpc' | 'gateway.close' | 'lease.create' | 'lease.bind' | 'lease.close'
   payload: Record<string, unknown>
   payload: Record<string, unknown>
   expiresAt: number
   expiresAt: number
 }
 }
@@ -27,3 +27,4 @@ export interface RunnerConfiguration {
   computers?: {network?:'none'|'public-proxy';runtime:'docker'|'podman';imageId:string;python:string;hermesSource:string;hermesHome:string;maxConcurrent?:number}
   computers?: {network?:'none'|'public-proxy';runtime:'docker'|'podman';imageId:string;python:string;hermesSource:string;hermesHome:string;maxConcurrent?:number}
   hermesCredentials?: {username:string;password:string}
   hermesCredentials?: {username:string;password:string}
 }
 }
+export const UNCONFIGURED_COMPUTER_IMAGE='sha256:'+'0'.repeat(64)

+ 26 - 0
tests/e2e/team-tools.spec.ts

@@ -188,3 +188,29 @@ test('trusted shared computers require an explicit data-sharing grant and can be
   const agents=(await (await page.request.get('/api/app/agents')).json()).agents
   const agents=(await (await page.request.get('/api/app/agents')).json()).agents
   expect(agents.find((agent:any)=>agent.id===fixture.agentId).computerEnvironmentId).toBeUndefined()
   expect(agents.find((agent:any)=>agent.id===fixture.agentId).computerEnvironmentId).toBeUndefined()
 })
 })
+
+
+test('always exposes computer management and performs image and sharing actions through the UI',async({page},testInfo)=>{
+ await page.goto('/conversations');await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click()
+ await expect(page.locator('.desktop-sidebar .environment-entry')).toBeVisible()
+ await page.request.post('/__test/image-management',{data:{}})
+ await page.locator('.desktop-sidebar .environment-entry button').click()
+ const hub=page.getByRole('dialog',{name:'电脑与环境',exact:true})
+ await expect(hub).toBeVisible();await expect(hub.getByText('尚未配置电脑',{exact:true}).first()).toBeVisible()
+ await hub.getByRole('button',{name:'镜像',exact:true}).click()
+ await expect(hub.getByRole('button',{name:'准备兼容镜像'})).toBeVisible()
+ const rows=hub.locator('.image-card');await expect(rows.first()).toContainText('当前使用')
+ await rows.nth(1).getByRole('button',{name:'应用',exact:true}).click()
+ await expect(rows.nth(1)).toContainText('当前使用')
+ await hub.getByRole('button',{name:'回退到上一镜像'}).click();await expect(rows.first()).toContainText('当前使用')
+ await rows.first().getByRole('button',{name:'导出',exact:true}).click()
+ await expect(hub.getByRole('link',{name:'下载镜像',exact:true})).toBeVisible()
+ const download=page.waitForEvent('download');await hub.getByRole('link',{name:'下载镜像',exact:true}).click();expect((await download).suggestedFilename()).toBe('computer-image.tar')
+ await page.setViewportSize({width:390,height:844});await page.emulateMedia({colorScheme:'dark',reducedMotion:'reduce'});await hub.getByRole('button',{name:'准备兼容镜像'}).scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('environment-images-mobile.png')})
+ await hub.getByRole('button',{name:'共享环境',exact:true}).click();await expect(hub.getByText('创建共享环境',{exact:true}).first()).toBeVisible()
+ await hub.getByRole('button',{name:'关闭',exact:true}).click()
+ await page.route('**/api/app/computers',route=>route.fulfill({status:404,contentType:'application/json',body:'{"error":"old server"}'}))
+ await page.setViewportSize({width:1280,height:900});await page.locator('.desktop-sidebar .environment-entry button').click()
+ await expect(page.getByRole('dialog',{name:'电脑与环境'})).toContainText('当前连接的服务尚未提供电脑管理')
+ await page.screenshot({path:testInfo.outputPath('environment-old-server.png')})
+})

+ 28 - 3
tests/fixtures/native-computer.py

@@ -1,10 +1,13 @@
 """Disposable UI fixture; use distinct ports for concurrent native clients."""
 """Disposable UI fixture; use distinct ports for concurrent native clients."""
-import argparse, base64, json, time, threading
+import argparse, base64, json, time, threading, hashlib
 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 from pathlib import Path
 from pathlib import Path
 parser=argparse.ArgumentParser();parser.add_argument('--port',type=int,required=True);parser.add_argument('--image',required=True);args=parser.parse_args()
 parser=argparse.ArgumentParser();parser.add_argument('--port',type=int,required=True);parser.add_argument('--image',required=True);args=parser.parse_args()
 image=base64.b64encode(Path(args.image).read_bytes()).decode()
 image=base64.b64encode(Path(args.image).read_bytes()).decode()
 state={'mode':'agent','generation':1,'controlId':None,'actions':[],'notes':''}
 state={'mode':'agent','generation':1,'controlId':None,'actions':[],'notes':''}
+agents=[dict(id='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',name='环境助手甲',avatar='',instructions='',nodeId='local',profile='default',archived=False,revision=1,execution='computer'),dict(id='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',name='环境助手乙',avatar='',instructions='',nodeId='local',profile='default',archived=False,revision=1,execution='computer')]
+agents[1].update(computerEnvironmentId='cccccccc-cccc-4ccc-8ccc-cccccccccccc',computerEnvironmentName='协作电脑')
+env={'legacy':False,'shared':[],'jobs':[],'image':'sha256:'+'a'*64,'previous':None,'uploaded':0}
 lock=threading.Lock()
 lock=threading.Lock()
 class Handler(BaseHTTPRequestHandler):
 class Handler(BaseHTTPRequestHandler):
     def log_message(self,*args): pass
     def log_message(self,*args): pass
@@ -12,19 +15,41 @@ class Handler(BaseHTTPRequestHandler):
         data=json.dumps(value,ensure_ascii=False).encode();self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data)
         data=json.dumps(value,ensure_ascii=False).encode();self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data)
     def do_GET(self):
     def do_GET(self):
         with lock:
         with lock:
-            if self.path=='/fixture/state': return self.respond(state)
+            if self.path=='/fixture/state': return self.respond({**state,**env})
+            if self.path=='/api/app/computers':
+                if env['legacy']: self.send_error(404);return
+                return self.respond({'canManageImages':True,'computers':[{'agent':a,'available':True,'reason':'可以打开电脑面板'} for a in agents]})
+            if self.path=='/api/app/computers/shared': return self.respond({'computers':env['shared']})
+            if self.path=='/api/app/admin/runners': return self.respond({'runners':[{'id':'11111111-1111-4111-8111-111111111111','name':'测试执行节点','enabled':True,'online':True}]})
+            if '/images/jobs/' in self.path and '/chunk' in self.path: return self.respond({'data':base64.b64encode(b'fixture-image').decode(),'offset':0,'size':13,'manifest':{'protocol':1}})
+            if self.path.endswith('/images'): return self.respond({'configured':True,'busy':False,'activeImageId':env['image'],'previousImageId':env['previous'],'images':[{'imageId':'sha256:'+'a'*64,'architecture':'arm64','driver':'0.20.0'},{'imageId':'sha256:'+'b'*64,'architecture':'arm64','driver':'0.20.0'}],'jobs':env['jobs'],'uploads':[]})
             if self.path.endswith('/frame'): return self.respond({'id':'22222222-2222-4222-8222-222222222222','generation':state['generation'],'data':image,'width':512,'height':512,'capturedAt':int(time.time()*1000)})
             if self.path.endswith('/frame'): return self.respond({'id':'22222222-2222-4222-8222-222222222222','generation':state['generation'],'data':image,'width':512,'height':512,'capturedAt':int(time.time()*1000)})
             if self.path.endswith('/computer'): return self.respond({**state,'canResume':True})
             if self.path.endswith('/computer'): return self.respond({**state,'canResume':True})
             return self.respond({'ok':True,'csrfToken':'fixture','features':['computerControl']})
             return self.respond({'ok':True,'csrfToken':'fixture','features':['computerControl']})
     def do_POST(self):
     def do_POST(self):
         data=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0'))) or b'{}')
         data=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0'))) or b'{}')
         with lock:
         with lock:
-            if self.path=='/fixture/reset': state.update(mode='agent',generation=1,controlId=None,actions=[],notes='')
+            if self.path=='/fixture/reset':
+                state.update(mode='agent',generation=1,controlId=None,actions=[],notes='');env.update(legacy=False,shared=[],jobs=[],image='sha256:'+'a'*64,previous=None,uploaded=0)
+            elif self.path=='/fixture/legacy': env['legacy']=True
+            elif self.path=='/api/app/computers/shared': env['shared'].append({'id':'cccccccc-cccc-4ccc-8ccc-cccccccccccc','name':data['name'],'memberIds':data['memberIds']})
+            elif self.path.endswith('/images/jobs'):
+                if data['operation'] in ['activate','rollback']: env['image'],env['previous']=(data.get('imageId') or env['previous']),env['image']
+                job={'id':data['requestId'],'operation':data['operation'],'state':'complete','message':'镜像操作完成','createdAt':1,'updatedAt':1}
+                if data['operation']=='export':job['archiveSize']=13
+                env['jobs'].insert(0,job)
+            elif self.path.endswith('/images/uploads'): return self.respond({'id':data['id'],'offset':0})
+            elif '/images/uploads/' in self.path:
+                env['uploaded']+=len(base64.b64decode(data['data']));return self.respond({'offset':env['uploaded']})
             elif self.path.endswith('/take'):
             elif self.path.endswith('/take'):
                 state.update(mode='human',generation=state['generation']+1,controlId='11111111-1111-4111-8111-111111111111')
                 state.update(mode='human',generation=state['generation']+1,controlId='11111111-1111-4111-8111-111111111111')
                 return self.respond({**state,'token':'fixture-private-control-token-1234567890','canResume':True})
                 return self.respond({**state,'token':'fixture-private-control-token-1234567890','canResume':True})
             elif self.path.endswith('/input'): state['actions'].append(data.get('action'))
             elif self.path.endswith('/input'): state['actions'].append(data.get('action'))
             elif self.path.endswith('/giveback'): state.update(mode='agent',generation=state['generation']+1,controlId=None,notes=data.get('notes',''))
             elif self.path.endswith('/giveback'): state.update(mode='agent',generation=state['generation']+1,controlId=None,notes=data.get('notes',''))
             return self.respond({'ok':True})
             return self.respond({'ok':True})
+    def do_DELETE(self):
+        with lock:
+            if '/computers/shared/' in self.path: env['shared']=[]
+            return self.respond({'ok':True})
 print(f'native computer fixture on 127.0.0.1:{args.port}',flush=True)
 print(f'native computer fixture on 127.0.0.1:{args.port}',flush=True)
 ThreadingHTTPServer(('127.0.0.1',args.port),Handler).serve_forever()
 ThreadingHTTPServer(('127.0.0.1',args.port),Handler).serve_forever()

+ 19 - 0
tests/fixtures/workspace-server.ts

@@ -378,6 +378,25 @@ runtime.app.use((ctx,next)=>{
   }
   }
   ctx.body={actions:[...computerFixtures.values()].flatMap(state=>state.actions)}
   ctx.body={actions:[...computerFixtures.values()].flatMap(state=>state.actions)}
 })
 })
+const imageFixture={activeImageId:'sha256:'+'a'.repeat(64),previousImageId:undefined as string|undefined,images:[{imageId:'sha256:'+'a'.repeat(64),architecture:'arm64',driver:'0.20.0'},{imageId:'sha256:'+'b'.repeat(64),architecture:'arm64',driver:'0.20.0'}],jobs:[] as any[],uploads:[] as any[]}
+let imageRunnerId=''
+const originalSummary=runtime.runners.summary.bind(runtime.runners),originalImages=runtime.runners.images.bind(runtime.runners)
+runtime.runners.summary=record=>record.id===imageRunnerId?{...originalSummary(record),online:true,features:['computer-worker-v1','computer-control-v1','image-management-v1','image-ready-v1']}:originalSummary(record)
+runtime.runners.images=async(owner,id,p)=>{
+ if(id!==imageRunnerId)return originalImages(owner,id,p)
+ if(p.op==='status')return {configured:true,busy:false,...imageFixture}
+ if(p.op==='start'){
+  if(p.operation==='activate'||p.operation==='rollback'){const old=imageFixture.activeImageId;imageFixture.activeImageId=String(p.operation==='rollback'?imageFixture.previousImageId:p.imageId);imageFixture.previousImageId=old}
+  const job={id:p.id,operation:p.operation,state:'complete',message:'操作完成',createdAt:Date.now(),updatedAt:Date.now(),...(p.operation==='export'?{archiveSize:7,manifest:{protocol:1}}:{})};imageFixture.jobs.unshift(job);return {job}
+ }
+ if(p.op==='download')return {data:Buffer.from('fixture').toString('base64'),offset:0,size:7,manifest:{protocol:1}}
+ if(p.op==='upload'){imageFixture.uploads.push({id:p.id,size:p.size,offset:0});return {id:p.id,offset:0}}
+ if(p.op==='append'){const upload=imageFixture.uploads.find(u=>u.id===p.id);upload.offset+=Buffer.from(String(p.data),'base64').length;return {offset:upload.offset}}
+ if(p.op==='remove-export'){delete imageFixture.jobs.find(j=>j.id===p.id).archiveSize;return {ok:true}}
+ if(p.op==='cancel-upload'){imageFixture.uploads=imageFixture.uploads.filter(u=>u.id!==p.id);return {ok:true}}
+ return {ok:true}
+}
+runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/image-management')return next();const owner=auth.requireAdmin(ctx).id;imageRunnerId=runtime.runners.enroll(owner,{name:'镜像管理验收节点',allowedProfiles:['default']}).runner.id;ctx.body={runnerId:imageRunnerId}})
 runtime.app.use(serve(resolve('dist')))
 runtime.app.use(serve(resolve('dist')))
 runtime.app.use((ctx) => {
 runtime.app.use((ctx) => {
   if (!ctx.path.startsWith('/api/')) {
   if (!ctx.path.startsWith('/api/')) {

+ 32 - 1
tests/live/worker.live.test.ts

@@ -89,6 +89,37 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     }else{const runner=new RunnerAgent(runnerConfig as any,runnerHome);running=runner.run(abort.signal)}
     }else{const runner=new RunnerAgent(runnerConfig as any,runnerHome);running=runner.run(abort.signal)}
     void running.catch(()=>{})
     void running.catch(()=>{})
     await expect.poll(()=>app.runners.summary(app.runners.records()[0]!).online).toBe(true)
     await expect.poll(()=>app.runners.summary(app.runners.records()[0]!).online).toBe(true)
+    const imageCookies=setup.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')
+    const imageBootstrap=await request(app.app.callback()).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',imageCookies).expect(200)
+    const imageBase=`/api/app/admin/runners/${registered.runner.id}/images`
+    const imagesGet=async()=>(await request(app.app.callback()).get(imageBase).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',imageCookies).expect(200)).body
+    const imagesPost=(body:unknown)=>request(app.app.callback()).post(imageBase+'/jobs').set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',imageCookies).set('X-CSRF-Token',imageBootstrap.body.csrfToken).send(body)
+    const prepareId=randomUUID();await imagesPost({requestId:prepareId,operation:'prepare'}).expect(202)
+    let prepared:any
+    await expect.poll(async()=>{prepared=(await imagesGet()).jobs.find((j:any)=>j.id===prepareId);return prepared?.state},{timeout:90000}).toMatch(/complete|failed/)
+    expect(prepared.state,JSON.stringify(prepared)).toBe('complete')
+    const applyId=randomUUID();await imagesPost({requestId:applyId,operation:'activate',imageId:prepared.imageId}).expect(202)
+    await expect.poll(async()=>(await imagesGet()).jobs.find((j:any)=>j.id===applyId)?.state,{timeout:60000}).toBe('complete')
+    expect((await imagesGet()).activeImageId).toBe(prepared.imageId)
+    const rollbackId=randomUUID();await imagesPost({requestId:rollbackId,operation:'rollback'}).expect(202)
+    await expect.poll(async()=>(await imagesGet()).jobs.find((j:any)=>j.id===rollbackId)?.state,{timeout:60000}).toBe('complete')
+    expect((await imagesGet()).activeImageId).toBe(process.env.YAOYAO_COMPUTER_IMAGE)
+    const exportId=randomUUID();await imagesPost({requestId:exportId,operation:'export',imageId:process.env.YAOYAO_COMPUTER_IMAGE}).expect(202)
+    let exportedImage:any
+    await expect.poll(async()=>{exportedImage=(await imagesGet()).jobs.find((j:any)=>j.id===exportId);return exportedImage?.state},{timeout:60000}).toBe('complete')
+    const uploadId=randomUUID()
+    const uploadAPI=(path:string,body:unknown)=>request(app.app.callback()).post(imageBase+path).set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',imageCookies).set('X-CSRF-Token',imageBootstrap.body.csrfToken).send(body)
+    await uploadAPI('/uploads',{id:uploadId,size:exportedImage.archiveSize,manifest:exportedImage.manifest}).expect(200)
+    const {createHash}=await import('node:crypto');const archiveHash=createHash('sha256')
+    for(let offset=0;offset<exportedImage.archiveSize;){
+      const part=(await request(app.app.callback()).get(imageBase+`/jobs/${exportId}/chunk?offset=${offset}`).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',imageCookies).expect(200)).body
+      const bytes=Buffer.from(part.data,'base64');archiveHash.update(bytes)
+      const accepted=await uploadAPI(`/uploads/${uploadId}`,{offset,data:part.data}).expect(200);offset+=bytes.length;expect(accepted.body.offset).toBe(offset)
+    }
+    expect(archiveHash.digest('hex')).toBe(exportedImage.manifest.archiveSha256)
+    const importId=randomUUID();await imagesPost({requestId:importId,operation:'import',uploadId}).expect(202)
+    await expect.poll(async()=>(await imagesGet()).jobs.find((j:any)=>j.id===importId)?.state,{timeout:60000}).toBe('complete')
+
     const worker=app.workspace.createAgent(owner,{name:'隔离执行成员',profile:'default',execution:'computer'})
     const worker=app.workspace.createAgent(owner,{name:'隔离执行成员',profile:'default',execution:'computer'})
     const direct=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===worker.id)!
     const direct=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===worker.id)!
     const run=app.workspaceRuntime.send(owner,direct.id,{requestId:randomUUID(),content:'在电脑里执行 fixture:shell 并汇报。'})
     const run=app.workspaceRuntime.send(owner,direct.id,{requestId:randomUUID(),content:'在电脑里执行 fixture:shell 并汇报。'})
@@ -191,4 +222,4 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     expect(app.workspace.list<any>(owner,'file').filter(file=>assignment.artifactIds.includes(file.id)).length).toBe(assignment.artifactIds.length)
     expect(app.workspace.list<any>(owner,'file').filter(file=>assignment.artifactIds.includes(file.id)).length).toBe(assignment.artifactIds.length)
 
 
   }finally{abort.abort();await running?.catch(()=>{});app.close();await Promise.all([server,upstream].map(server=>new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()})));await rm(home,{recursive:true,force:true})}
   }finally{abort.abort();await running?.catch(()=>{});app.close();await Promise.all([server,upstream].map(server=>new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()})));await rm(home,{recursive:true,force:true})}
-},180000)
+},420000)

+ 48 - 0
tests/server/imageManagement.test.ts

@@ -0,0 +1,48 @@
+// @vitest-environment node
+import {it,expect,vi} from 'vitest'
+import {DatabaseSync} from 'node:sqlite'
+import {mkdtemp,rm,writeFile} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {ComputerRuntime} from '../../src/runner/worker/gateway'
+import {ImageManagement} from '../../src/runner/computers/imageManagement'
+import {fileSHA256} from '../../src/runner/computers/images'
+it('persists image selection, rejects in-use switching and recovers interrupted jobs without replay',async()=>{
+ const home=await mkdtemp(join(tmpdir(),'yaoyao-images-ui-')),db=new DatabaseSync(':memory:'),initial='sha256:'+'a'.repeat(64),next='sha256:'+'b'.repeat(64)
+ const config:any={protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:initial,python:'/fixture/python',hermesSource:'/fixture/source',hermesHome:'/fixture/home'}}
+ const runtime=new ComputerRuntime(db,config,home);let manager:ImageManagement|undefined
+ try{await runtime.ready;manager=new ImageManagement(runtime,db,home);await manager.ready
+  const manifest={protocol:1 as const,imageId:next,architecture:'arm64' as const,driver:'0.20.0' as const,layer:'5' as const,createdAt:1}
+  vi.spyOn(manager.images,'inspect').mockResolvedValue(manifest)
+  vi.spyOn(runtime.provider,'verifyRuntime').mockResolvedValue();vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id:'fixture',containerId:'fixture',workspace:'/fixture',running:true,isolation:'container'});vi.spyOn(runtime.provider,'health').mockResolvedValue({});vi.spyOn(runtime.provider,'capture').mockResolvedValue({data:'fixture',width:1,height:1,capturedAt:1});vi.spyOn(runtime.provider,'remove').mockResolvedValue();vi.spyOn(runtime.provider,'deleteWorkspace').mockResolvedValue()
+  const release=runtime.pool.beginMaintenance();await expect(manager.start('owner',randomUUID(),'activate',{imageId:next},async()=>{})).rejects.toMatchObject({code:'computer_busy'});release()
+  const id=randomUUID();await manager.start('owner',id,'activate',{imageId:next},async()=>{});await vi.waitFor(()=>expect(runtime.config.imageId).toBe(next));await manager.close()
+  expect(JSON.parse(String(db.prepare('SELECT value FROM managed_image_selection').get()!.value))).toMatchObject({active:next,previous:initial,base:initial})
+  config.computers.imageId=initial
+  const resumed=new ImageManagement(runtime,db,home);await resumed.ready;expect(runtime.config.imageId).toBe(next);await resumed.close()
+ }finally{await manager?.close();await runtime.controls.close();await runtime.pool.close();db.close();await rm(home,{recursive:true,force:true})}
+})
+it('uploads ordered bounded chunks, detects conflicting retries and isolates the uploader',async()=>{
+ const home=await mkdtemp(join(tmpdir(),'yaoyao-images-upload-')),db=new DatabaseSync(':memory:'),runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:'sha256:'+'a'.repeat(64),python:'/fixture',hermesHome:'/fixture',hermesSource:'/fixture'}},home)
+ const manager=new ImageManagement(runtime,db,home)
+ try{await manager.ready;const id=randomUUID(),data=Buffer.from('image-fixture');const path=join(home,'hash');await writeFile(path,data)
+  await manager.beginUpload('owner',id,data.length,{protocol:1,imageId:'sha256:'+'a'.repeat(64),architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1,archiveSha256:await fileSHA256(path)})
+  const bytes=data.toString('base64');expect(await manager.appendUpload('owner',id,0,bytes)).toEqual({offset:data.length});expect(await manager.appendUpload('owner',id,0,bytes)).toEqual({offset:data.length})
+  await expect(manager.appendUpload('other',id,0,bytes)).rejects.toMatchObject({code:'image_upload_missing'})
+  await expect(manager.appendUpload('owner',id,0,Buffer.from('wrong').toString('base64'))).rejects.toThrow()
+  await manager.cancelUpload('owner',id)
+ }finally{await manager.close();await runtime.controls.close();await runtime.pool.close();db.close();await rm(home,{recursive:true,force:true})}
+})
+it('keeps the node in maintenance until uncertain validation cleanup is confirmed',async()=>{
+ const home=await mkdtemp(join(tmpdir(),'yaoyao-images-cleanup-')),db=new DatabaseSync(':memory:'),initial='sha256:'+'a'.repeat(64)
+ const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:initial,python:'/fixture',hermesHome:'/fixture',hermesSource:'/fixture'}},home),manager=new ImageManagement(runtime,db,home)
+ let stopped=false
+ try{await manager.ready;vi.spyOn(manager.images,'inspect').mockResolvedValue({protocol:1,imageId:'sha256:'+'b'.repeat(64),architecture:'arm64',driver:'0.20.0',layer:'5',createdAt:1});vi.spyOn(manager.images,'run').mockResolvedValue('');vi.spyOn(runtime.provider,'verifyRuntime').mockResolvedValue();vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id:'fixture',containerId:'fixture',running:true,workspace:'/fixture',isolation:'container'});vi.spyOn(runtime.provider,'health').mockResolvedValue({});vi.spyOn(runtime.provider,'capture').mockResolvedValue({data:'fixture',width:1,height:1,capturedAt:1});vi.spyOn(runtime.provider,'remove').mockImplementation(async()=>{if(!stopped)throw new Error('stop uncertain')});vi.spyOn(runtime.provider,'deleteWorkspace').mockResolvedValue()
+  await manager.start('owner',randomUUID(),'activate',{imageId:'sha256:'+'b'.repeat(64)},async()=>{})
+  await vi.waitFor(async()=>expect((await manager.inventory('owner')).jobs[0]?.state).toBe('uncertain'))
+  expect(()=>runtime.pool.beginMaintenance()).toThrow('请先停止');expect(runtime.config.imageId).toBe(initial)
+  stopped=true;expect((await manager.inventory('owner')).busy).toBe(false)
+  const release=runtime.pool.beginMaintenance();release()
+ }finally{stopped=true;await manager.close();await runtime.controls.close();await runtime.pool.close();db.close();await rm(home,{recursive:true,force:true})}
+})

+ 24 - 0
tests/server/imageManagementRoutes.test.ts

@@ -0,0 +1,24 @@
+// @vitest-environment node
+import {it,expect,vi} from 'vitest'
+import Koa from 'koa'
+import {bodyParser} from '@koa/bodyparser'
+import request from 'supertest'
+import {mkdtempSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {ImageManagementService} from '../../src/server/imageManagement'
+import {HttpError} from '../../src/server/errors'
+it('restricts image operations to admins and fences job grants to user version and Runner',async()=>{
+ const home=mkdtempSync(join(tmpdir(),'yaoyao-image-route-')),store=new WorkspaceStore(home);let version=1,active=true
+ const auth:any={requireAdmin:(ctx:any)=>{if(ctx.get('x-admin')!=='yes')throw new HttpError(403,'管理员权限','admin_required');return {id:'owner'}},isAdminActive:()=>active,pushAuthorizationVersion:()=>version}
+ const images=vi.fn(async(owner:string,runnerId:string,p:any)=>({owner,runnerId,job:{id:p.id}})),service=new ImageManagementService(store,auth,{images} as any),app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(e){ctx.status=(e as any).status||500;ctx.body={error:String(e)}}});app.use(bodyParser());app.use(service.router().routes())
+ try{const runner=randomUUID(),id=randomUUID(),base=`/api/app/admin/runners/${runner}/images`
+  await request(app.callback()).post(base+'/jobs').send({requestId:id,operation:'prepare'}).expect(403);expect(images).not.toHaveBeenCalled()
+  await request(app.callback()).post(base+'/jobs').set('x-admin','yes').send({requestId:id,operation:'prepare',path:'/etc/passwd'}).expect(400)
+  await request(app.callback()).post(base+'/jobs').set('x-admin','yes').send({requestId:id,operation:'prepare'}).expect(202)
+  expect(service.allowed(id,runner)).toBe(true);expect(service.allowed(id,randomUUID())).toBe(false)
+  version++;expect(service.allowed(id,runner)).toBe(false);version--;active=false;expect(service.allowed(id,runner)).toBe(false)
+ }finally{store.close();rmSync(home,{recursive:true,force:true})}
+})