Quellcode durchsuchen

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

LSamien vor 1 Monat
Ursprung
Commit
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 是明确的扩展边界。
 
 配套说明:`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。
 
 模型循环使用真实 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 构建尚未验收。
 
 真实电脑备份/恢复后重新启动并读取原文件通过,恢复前文件保留在旧数据副本。新镜像的打包 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 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">
 import { onMounted, ref } from 'vue'
 import { apiRequest } from '@/api/client'
+import { UNCONFIGURED_COMPUTER_IMAGE } from '@shared/runner'
 import type { RunnerConfiguration, RunnerRecord } from '@shared/runner'
 
 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(!['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))]
-    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}})
-    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。'
     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="computerNetwork"><option value="public-proxy">公网网页(HTTP / HTTPS)</option><option value="none">不联网</option></select></label>
         <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 源码目录<input v-model.trim="hermesSource" required placeholder="/完整路径/hermes-agent"></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>
       <button class="solid-button" :disabled="busy || !name.trim() || !profiles.trim()">注册节点</button>
     </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 RemoteAgentPicker from '@/components/workspace/RemoteAgentPicker.vue'
 import ConversationList from '@/components/workspace/ConversationList.vue'
+import ComputerEnvironmentHub from '@/components/workspace/ComputerEnvironmentHub.vue'
 import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
 import TaskPlan from '@/components/workspace/TaskPlan.vue'
 import type { AgentAssignment } from '@shared/agentTasks'
@@ -185,6 +186,7 @@ let cursor = 0,
   pendingFingerprint = ''
 const selected = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
 const selectedTask = computed(() => typeof route.query.taskId === 'string' ? route.query.taskId : '')
+const environmentOpen=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 members = computed(() => agents.value.filter((a) => active.value?.memberIds.includes(a.id)))
@@ -647,10 +649,10 @@ onBeforeUnmount(() => {
     @create-remote-agent="remotePickerOpen = true"
   >
     <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 #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>
     <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
       <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} `})) : []"
         @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event" />
     </section>
+    <ComputerEnvironmentHub v-if="environmentOpen" @close="environmentOpen=false" @changed="refresh()" />
     <ComputerPanel v-if="computerOpen" :agents="computerAgents" @changed="load()" @close="computerOpen=false" />
     <FloatingResourceSearch section="groups" label="搜索聊天" :items="searchItems" tabbed @select="select" />
     <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
@@ -829,6 +832,7 @@ onBeforeUnmount(() => {
   </WorkspaceShell>
 </template>
 <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}
 .editor {
   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 { realpath,access } from 'node:fs/promises'
 import { resolve, sep, join } from 'node:path'
+import {ImageManagement} from './computers/imageManagement.js'
 import {ComputerError} from './computers/container.js'
 import {z} from 'zod'
 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 { WorkspaceGateway, type GatewayTarget, type GatewayFrame } from '../server/workspaceGateway.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 { LoopbackTransport, isLocalAuthorizationTarget } from '../server/loopbackAuthorization.js'
 
@@ -30,6 +32,7 @@ export class RunnerAgent {
   private inflight=new Map<string,Promise<unknown>>()
   private db:DatabaseSync
   private computers?:ComputerRuntime
+  private imageManagement?:ImageManagement
   private target:GatewayTarget
   private controlTransport=new LoopbackTransport()
   private serverEpoch?:string
@@ -43,7 +46,7 @@ export class RunnerAgent {
     this.target={url:hermes,client,session:new UpstreamServiceSession(client,()=>config.hermesCredentials)}
     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)')
-    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()
   }
   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'
     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)]),
-      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)}),
     })
     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> {
     const p=command.payload
     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(!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)
@@ -264,7 +279,7 @@ export class RunnerAgent {
       try{
         await this.disconnect().catch(()=>{})
         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('执行节点已停止')}
     }
   }

+ 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 ready=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) {
     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)')
@@ -49,6 +55,7 @@ export class ComputerPool {
     if(!holderId||holderId.length>256)throw new ComputerError('computer_holder_invalid','电脑任务身份无效')
     return this.serial(spec.id,async()=>{
       authorize()
+      if(this.maintenance)throw new ComputerError('computer_busy','执行节点正在管理镜像,请稍后重试')
       if(!this.ready||this.closing)throw new ComputerError('computer_pool_unavailable','电脑资源正在恢复或关闭')
       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','电脑环境归属或配置不匹配')
@@ -73,7 +80,8 @@ export class ComputerPool {
   async configure(spec:ComputerSpecification,authorize:()=>void):Promise<void>{
     spec=this.provider.validateSpecification(spec)
     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
       if(entry.spec.ownerKey!==spec.ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
       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 {HttpError} from '../../server/errors.js'
 import type {GatewayFrame} from '../../server/workspaceGateway.js'
+import {UNCONFIGURED_COMPUTER_IMAGE} from '../../shared/runner.js'
 import type {RunnerConfiguration} from '../../shared/runner.js'
 import {ContainerComputerProvider,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.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')
       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(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)
       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:[]}

+ 5 - 1
src/server/app.ts

@@ -49,6 +49,7 @@ import {
   HermesChatNotificationResolver,
 } from './pushEvents.js'
 import { ChatCacheCoordinator, ChatCacheStore } from './chatCache.js'
+import {ImageManagementService} from './imageManagement.js'
 import {SharedComputers} from './sharedComputers.js'
 import {ComputerControlService} from './computerControls.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 sharedComputers=new SharedComputers(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)
   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)
@@ -309,7 +312,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
       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 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'
       const pushPath = /^\/api\/(?:app\/)?push\/v1\//.test(path)
       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 imageRouter=imageManagement.router();app.use(imageRouter.routes());app.use(imageRouter.allowedMethods())
   const computerRouter=computerControls.router();app.use(computerRouter.routes());app.use(computerRouter.allowedMethods())
   const applicationRouter = workspaceRouter(workspace, workspaceRuntime, workspaceNodes, workspaceAssets, uploads, auth, push)
   app.use(runners.adminRouter().routes())

+ 8 - 0
src/server/computerControls.ts

@@ -30,6 +30,14 @@ export class ComputerControlService {
   }
   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=>{
       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)})

+ 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))))
   }
 
+  isAdminActive(userID:string):boolean {return this.#users.some(user=>user.id===userID&&user.enabled&&user.role==='admin')}
+
   isUserActive(userID: string): boolean {
     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)
     },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')}
   enroll(owner:string,value:unknown) {
     const body=parse(input,value)
@@ -170,7 +180,7 @@ export class RunnerHub {
   }
   middleware():Koa.Middleware {
     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()
       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))))
@@ -187,7 +197,7 @@ export class RunnerHub {
         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')
-      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')
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')
@@ -208,6 +218,7 @@ export class RunnerHub {
       let body:any
       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(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]==='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)

+ 1 - 1
src/server/workspaceRoutes.ts

@@ -67,7 +67,7 @@ export function workspaceRouter(
         'conversationTasks',
         'editableGroups',
         'agentTeamManagement',
-        'computerControl','sharedComputers',
+        'computerControl','sharedComputers','computerManagement',
         'files',
         'voice',
         '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 {
   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>
   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}
   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
   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."""
-import argparse, base64, json, time, threading
+import argparse, base64, json, time, threading, hashlib
 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 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()
 image=base64.b64encode(Path(args.image).read_bytes()).decode()
 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()
 class Handler(BaseHTTPRequestHandler):
     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)
     def do_GET(self):
         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('/computer'): return self.respond({**state,'canResume':True})
             return self.respond({'ok':True,'csrfToken':'fixture','features':['computerControl']})
     def do_POST(self):
         data=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0'))) or b'{}')
         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'):
                 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})
             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',''))
             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)
 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)}
 })
+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((ctx) => {
   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)}
     void running.catch(()=>{})
     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 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 并汇报。'})
@@ -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)
 
   }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})}
+})