浏览代码

接入隔离 Hermes Worker 与任务产物回传

LSamien 1 月之前
父节点
当前提交
fecc05b345
共有 39 个文件被更改,包括 1083 次插入 和 88 次删除
  1. 9 2
      desktop/main.mjs
  2. 6 3
      desktop/runner-manager.mjs
  3. 8 0
      docs/agent-platform-implementation.md
  4. 3 3
      docs/computer-provider.md
  5. 59 0
      docs/computer-worker.md
  6. 1 1
      docs/runner.md
  7. 1 0
      electron-builder.yml
  8. 1 1
      package.json
  9. 2 1
      scripts/build-runner.mjs
  10. 5 0
      scripts/copy-worker.mjs
  11. 14 1
      src/client/components/app/RunnerSettingsPanel.vue
  12. 15 5
      src/client/views/ConversationsView.vue
  13. 45 9
      src/runner/agent.ts
  14. 20 11
      src/runner/computers/container.ts
  15. 22 1
      src/runner/computers/pool.ts
  16. 1 0
      src/runner/config.ts
  17. 1 1
      src/runner/index.ts
  18. 219 0
      src/runner/worker/gateway.ts
  19. 172 0
      src/runner/worker/hermes_worker.py
  20. 59 0
      src/runner/worker/process.ts
  21. 2 1
      src/server/app.ts
  22. 54 16
      src/server/runnerHub.ts
  23. 6 3
      src/server/taskCoordinator.ts
  24. 20 0
      src/server/workspaceAssets.ts
  25. 14 6
      src/server/workspaceGateway.ts
  26. 9 5
      src/server/workspaceRoutes.ts
  27. 33 10
      src/server/workspaceRuntime.ts
  28. 7 4
      src/server/workspaceScheduler.ts
  29. 3 0
      src/server/workspaceStore.ts
  30. 11 3
      src/server/workspaceTeamTools.ts
  31. 1 0
      src/shared/runner.ts
  32. 2 0
      src/shared/workspace.ts
  33. 26 1
      tests/e2e/team-tools.spec.ts
  34. 126 0
      tests/live/worker.live.test.ts
  35. 28 0
      tests/server/computerArtifacts.test.ts
  36. 10 0
      tests/server/computerPool.test.ts
  37. 35 0
      tests/server/runner.test.ts
  38. 28 0
      tests/server/workspace.test.ts
  39. 5 0
      tests/server/workspaceTeamTools.test.ts

+ 9 - 2
desktop/main.mjs

@@ -100,7 +100,14 @@ else {
     event.preventDefault()
     if (closing) return
     closing = true; clearInterval(timer)
-    void (async()=>{await runnerManager?.stop().catch(error=>log(error.message));await manager?.stop().catch(error=>log(error.message))})().finally(() => { quitting = true; app.quit() })
+    void (async()=>{
+      try{await runnerManager?.stop();await manager?.stop();quitting=true;app.quit()}
+      catch(error){
+        log(error.message)
+        const choice=await dialog.showMessageBox(window,{type:'warning',message:'服务清理尚未完成',detail:error.message,buttons:['继续运行','仍然退出'],defaultId:0,cancelId:0})
+        if(choice.response===1){quitting=true;app.quit()}else{closing=false;timer=setInterval(()=>{void manager.check()},10000);timer.unref()}
+      }
+    })()
   })
   app.whenReady().then(async () => {
     const packageVersion = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).webVersion
@@ -122,7 +129,7 @@ else {
       encrypt:value=>{if(!safeStorage.isEncryptionAvailable())throw new Error('系统钥匙串不可用,无法保存执行节点凭据');return safeStorage.encryptString(value)},
       decrypt:bytes=>safeStorage.decryptString(bytes),
       onState:message=>{log(`Runner: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('runner-status');if(item)item.label=message},
-      fork:()=>utilityProcess.fork(join(root,'runner.mjs'),['--desktop-ipc'],{cwd:root,stdio:'pipe',env:{PATH:process.env.PATH||'/usr/bin:/bin',NODE_ENV:'production',NODE_USE_ENV_PROXY:'0'}}),
+      fork:()=>utilityProcess.fork(join(root,'runner.mjs'),['--desktop-ipc'],{cwd:root,stdio:'pipe',env:{HOME:homedir(),PATH:[join(homedir(),'.local/bin'),join(homedir(),'.orbstack/bin'),'/opt/homebrew/bin','/usr/local/bin',process.env.PATH||'/usr/bin:/bin'].join(':'),NODE_ENV:'production',NODE_USE_ENV_PROXY:'0'}}),
     })
     async function runnerAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'执行节点操作未完成',detail:error.message})}}
     async function importRunner(){

+ 6 - 3
desktop/runner-manager.mjs

@@ -47,6 +47,7 @@ export class DesktopRunnerManager {
     if(this.child || this.stopping) return
     const child = this.options.fork()
     this.child = child
+    this.stopFailure = undefined
     // Drain diagnostics without persisting possible upstream or parser secrets.
     child.stdout?.resume();child.stderr?.resume()
     this.publish('执行节点正在连接')
@@ -59,7 +60,8 @@ export class DesktopRunnerManager {
       if(['status','error'].includes(message?.type) && typeof message.message === 'string') this.publish(message.message.slice(0,200))
     })
     child.exited = new Promise(resolve => child.once('exit', code => {
-      child.finished = true
+      child.finished = true;child.exitCode=code
+      if(this.stopping&&code!==0)this.stopFailure=new Error('执行节点异常退出,电脑停止状态待确认')
       resolve()
       if(this.child !== child) return
       this.child = null
@@ -76,10 +78,10 @@ export class DesktopRunnerManager {
   async stopChild() {
     this.stopping = true; clearTimeout(this.restartTimer)
     const child = this.child
-    if(!child) return
+    if(!child){if(this.stopFailure)throw this.stopFailure;return}
     child.postMessage({type:'shutdown'})
     let timer
-    try { await Promise.race([child.exited,new Promise(resolve => { timer = setTimeout(resolve,5000) })]) }
+    try { await Promise.race([child.exited,new Promise(resolve => { timer = setTimeout(resolve,30000) })]) }
     finally { clearTimeout(timer) }
     if(!child.finished) {
       child.kill()
@@ -88,6 +90,7 @@ export class DesktopRunnerManager {
     }
     if(!child.finished) throw new Error('执行节点尚未确认退出,暂不启动替代进程')
     if(this.child === child) this.child = null
+    if(this.stopFailure)throw this.stopFailure
   }
   stop() { this.generation++;this.stopping = true; clearTimeout(this.restartTimer); return this.serial(() => this.stopChild()) }
   forget() { this.generation++;this.stopping = true; return this.serial(async () => { await this.stopChild(); await rm(this.path,{force:true}); this.publish('未配置执行节点') }) }

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

@@ -69,3 +69,11 @@
 - 电脑 Provider 基础:实现私有容器、归属与约束校验、持久工作目录、guest 执行和取消时的停止确认。15 项 Docker/Podman 离线测试通过;真实 Docker 无网络环境的创建、画面读取、持久文件重建和取消写入验收通过,截图已核对,测试容器已清理。详见 `docs/computer-provider.md`。尚未接入任务资源租约和受控 Worker,VM 功能仍未完成。
 - 电脑租约基础:SQLite 保存持有者、递增代次、期限与停止状态;配额在启动前预占,未确认停止的资源保持占用,取消后退休旧容器 ID。资源池 6 项测试及接入资源池后的真实 Docker 生命周期测试通过。仍未将该资源池接入 Bot 任务和受控 Worker。
 - 当前源码验证:类型检查、711 项全量测试通过;2 项真实环境测试默认按需跳过,已分别执行过真实 Hermes Runner 协议和真实 Docker 电脑/租约验收。所有电脑验收容器均已清理。整体目标仍在进行,下一步是受控 Worker、联网代理及任务/权限接入,随后完成跨端电脑界面与其余交付项。
+
+- 受控 Worker 阶段:Bot 执行方式可选择隔离电脑,已接入真实 Hermes 模型循环、任务授权与持久电脑租约。模型循环在 Runner 侧,工具和工具子进程在 guest 内;凭据不传入电脑环境。详见 `docs/computer-worker.md`。
+- 完整 Runner 二进制验收经过控制面 → Runner → Hermes Worker → 真实 Docker;验证 UID 1000 执行、配置中的 cwd、同一 Agent 更新 cwd 后继续执行、宿主原生工具拒绝、团队创建、原会话结果、文件附件下载及多块二进制导出。模型服务使用确定的测试响应,没有调用付费模型。
+- 增加执行节点能力协商,旧 Runner 不支持隔离时明确拒绝;未提交的资源不足任务保留排队,恢复后只提交一次。历史消息记录原执行环境,后续改变 Agent 设置也不会把原隔离消息的路径交给宿主读取。
+- 产物校验后保留原作者/消息归属,支持原有下载接口、转交与任务复核附件。类型检查和 718 项测试通过;4 项真实环境测试默认跳过,Worker 的真实环境与打包 Runner 版本已单独执行。
+- 全目标仍未完成:优先继续受控联网、镜像准备/升级、临时助手、屏幕查看与人工接管、共享环境、移动端入口及后台/更新交付项。不能将 Worker 链路完成视作完整平台交付。
+- 本阶段最终检查:类型检查与 719 项全量测试通过,4 项真实环境测试默认按需跳过;打包 Runner 的真实 Worker 测试 2 项通过,包含原生宿主工具拒绝、分块二进制产物、下载、cwd 变更和取消延迟写入。Web 4 项入口测试及桌面 4 项生命周期测试通过;窄屏弹窗截图已核对。测试电脑容器全部清理。
+- 停止与撤权分开处理:撤销执行授权不阻断已绑定任务的清理,只允许清理原会话;清理通道不能提交新工作。电脑恢复首先核对并停止原持有者,旧容器身份不会被新任务复用。

+ 3 - 3
docs/computer-provider.md

@@ -1,6 +1,6 @@
 # 私有电脑 Provider 基础
 
-实现位于 `src/runner/computers/container.ts`,提供统一的创建、查询、执行、停止和删除接口。每个环境有独立 UUID、账号归属键、Runner 身份、容器和持久工作目录。`pool.ts` 提供持久电脑租约与资源配额。此阶段尚未将电脑分配接入 Bot 任务,也未提供受控 Hermes Worker;这是后续任务接入的执行底座。
+实现位于 `src/runner/computers/container.ts`,提供统一的创建、查询、执行、停止和删除接口。每个环境有独立 UUID、账号归属键、Runner 身份、容器和持久工作目录。`pool.ts` 提供持久电脑租约与资源配额。现在已通过受控 Worker 接入 Bot 任务,详见 `docs/computer-worker.md`。本文记录 Provider 和资源租约本身的边界。
 
 ## 已实现的约束
 
@@ -14,7 +14,7 @@
 - 取消或无法确认的 guest 命令会停止所属私有容器,确认停止后才释放串行执行位置。仅终止 `docker exec` 客户端不能证明 guest 进程已停止。
 - 删除容器保留工作目录,重建后可继续读取工作文件。工作区快照、恢复、显式删除及共享桌面尚未实现。
 
-权限校验以回调传入,在异步检查与实际执行的前后重验。ComputerPool 在 SQLite 中保存持有者、租约代次、期限与停止状态,默认每个 Runner 同时 2 个环境、总计最多 32 个环境。启动时必须先恢复未确认停止的资源;旧租约先失效,再停止并删除旧容器身份,保留工作区。不确认停止的环境保持占用,不能交给新任务。当前 Provider 与资源池都不作为裸命令接口对浏览器或模型开放,尚待接入运行中的任务权限和生命周期。
+权限校验以回调传入,在异步检查与实际执行的前后重验。ComputerPool 在 SQLite 中保存持有者、租约代次、期限与停止状态,默认每个 Runner 同时 2 个环境、总计最多 32 个环境。启动时必须先恢复未确认停止的资源;旧租约先失效,再停止并删除旧容器身份,保留工作区。不确认停止的环境保持占用,不能交给新任务。当前 Provider 与资源池都不作为裸命令接口对浏览器或模型开放,通过 Worker 和任务授权检查使用。
 
 ## 参考与差异
 
@@ -31,4 +31,4 @@ YAOYAO_COMPUTER_IMAGE=sha256:本机已验证镜像的完整ID \
   npx vitest run tests/live/computer.live.test.ts
 ```
 
-本轮 arm64 验证镜像为 `sha256:574b51992c53e22d48997d4286ba83e47b872de34250276c99d2f390bf3640e0`。独立镜像构建/交付、联网、Worker、任务租约接入和跨端电脑面板仍待完成,不能以本测试替代整个 VM 功能验收。
+本轮 arm64 验证镜像为 `sha256:574b51992c53e22d48997d4286ba83e47b872de34250276c99d2f390bf3640e0`。独立镜像构建/交付、联网、跨端电脑面板和共享环境仍待完成,不能以本测试替代整个 VM 功能验收。

+ 59 - 0
docs/computer-worker.md

@@ -0,0 +1,59 @@
+# 受控 Hermes Worker 与 Bot 任务
+
+当前分支已将 Bot 的“隔离电脑”执行方式接入 Runner。用户仍使用原有 Agent、群聊、任务和权限接口;底层使用独立的电脑环境执行工具。
+
+## 执行链路
+
+```mermaid
+flowchart LR
+  UI[Web / macOS / 移动端] --> CP[夭夭任务协调器]
+  CP --> HUB[Runner 命令与授权通道]
+  HUB --> W[受控 Hermes 模型循环]
+  W --> G[工具分发与任务授权检查]
+  G --> VM[隔离 Linux 电脑]
+  VM --> A[分块产物校验]
+  A --> CP
+```
+
+模型循环进程运行在 Runner 侧,模型凭据仅通过私有进程管道传入。文件、终端、桌面及这些工具启动的进程在 guest 内执行;模型循环不使用原生 Hermes 工具执行分支。隔离电脑不挂载 Hermes 配置、密钥、宿主 HOME 或 Docker socket。
+
+Worker 复用已安装 Hermes 的模型循环。它截获整个工具执行入口,覆盖串行、并行及内联工具分支,只接受本轮注入的工具。未知的 `terminal`、委派或消息工具不能进入宿主原生处理器。模型收到的工具目录只包含当前 Worker 的工具;禁用宿主上下文文件、记忆和后台复核。
+
+## 使用
+
+1. 在“设置 → Hermes 连接 → 执行节点”注册 Runner,选择“启用隔离电脑 Worker”,填写兼容镜像完整 ID、Hermes Python、源码与配置目录。
+2. 下载配置后,通过 macOS App 的“执行节点 → 导入节点配置…”连接,或使用 Runner CLI。
+3. 将 Agent 的“执行环境”设置为“隔离电脑”。已有普通 Agent 保持原执行方式;执行中不能切换环境。
+4. 基础 Profile 必须配置可用于 guest 的绝对 `terminal.cwd`。Runner 读取并解析该配置,将私有工作区映射到相同的 guest 路径。路径相同不代表共享宿主目录。配置变更在下一轮应用,旧电脑身份退役,持久文件保留。
+
+当前 Worker 模型连接支持 Hermes 的 `chat_completions`、`anthropic_messages`、`codex_responses` API 模式;不启动宿主 CLI 模型后端。模型设置和凭据来自允许列表内的 Profile。
+
+## 权限和生命周期
+
+- 控制面在执行节点、账号、基础 Profile、Agent、聊天成员关系和任务状态上共同检查授权。检查发生在命令准入、电脑操作、续期和产物传输时。
+- Runner 必须明确声明 `computer-worker-v1` 能力;旧 Runner 或未配置电脑的 Runner 会被拒绝,不会把隔离任务当普通宿主任务运行。
+- 电脑租约持有者由服务端生成的调度轮次确定。撤权、停止和连接代次变化结束旧控制权;下一持有者不会复用旧容器 ID。
+- 资源不足或 Runner 离线时,未提交的任务排队等待,不重新提交已运行的模型轮次,也不消耗子任务返工次数。
+- 普通成员不获得团队管理工具。隔离管理员可以使用当前轮次的团队工具,其新建成员默认继续隔离,不能要求新成员改为宿主执行。
+- 模型凭据不进入 guest、进程命令行或日志。macOS App 用系统加密存储保存 Runner 配置。
+
+## 文件和产物
+
+用户附件和分派所需文件通过受控输入写入 guest。`computer_export` 将文件复制为本次导出快照,按块传输,并在控制面校验大小与 SHA-256;每个产物最多 25 MiB,每轮最多 8 个。成功后以正常文件附件出现,支持原有的预览、下载和移动端协议。
+
+产物保留原作者消息和 Profile 归属。任务依赖、管理员复核和结果回传会附上相应文件;转交不会重写原产物来源。隔离消息中的任意路径不会交给宿主 Hermes 文件接口自动读取。
+
+## 已验证
+
+- 真正运行 Hermes 模型循环,测试模型服务返回工具请求;授权工具回到父进程,宿主 `terminal` 请求被拒绝,未产生宿主写入。
+- Bot 消息经过完整控制面、Runner、Hermes Worker、真实 Docker,guest UID 1000 写入文件,结果回到原会话。
+- 产物进入文件附件,并通过受保护下载接口返回真实内容;验证多块二进制传输及完整大小。
+- 同一 Agent 在 Profile cwd 变更后继续执行;停止正在运行的 shell 后未出现延迟写入。
+- 隔离管理员通过本轮团队工具创建隔离成员,创建来源记录正确,新成员没有团队管理权限。
+- 打包 Runner 的真实 Worker 链路另行执行,验证 Python 入口打包位置及启动方式。
+
+测试模型服务使用确定的协议响应,没有调用付费模型。这些证据证明执行链路与边界,不代表真实模型在任意复杂任务下的自主决策质量。
+
+## 仍需完成
+
+电脑目前不联网。受控公网代理、镜像安装/更新、临时助手、屏幕查看/接管、可信团队共享环境,以及移动端执行方式设置等仍属于完整目标的后续工作。现有移动端已可使用服务端创建的隔离 Agent、接收结果和文件,但不能据此宣称完整电脑界面已交付。

+ 1 - 1
docs/runner.md

@@ -2,7 +2,7 @@
 
 Runner 在运行 Hermes 的电脑上主动连接夭夭服务。用于 Web 位于 Docker、另一台电脑或远程服务器时,将 Bot 会话和本轮组队工具交给 Hermes 所在电脑执行。Runner 源码、构建入口和管理页面均位于 hermes-yaoyao。
 
-这是执行传输层。当前接入的 Hermes 仍使用它原有的工具和权限,Runner 本身不提供 VM 隔离。隔离电脑、受控 Worker、共享桌面和资源租约属于后续实现范围。
+普通执行方式继续使用原有 Hermes。配置电脑 Worker 后,Agent 可选择隔离电脑执行,模型工具不会退回宿主。具体链路与验证边界见 `docs/computer-worker.md`;联网、屏幕接管与共享环境仍待完成。
 
 ## 启用
 

+ 1 - 0
electron-builder.yml

@@ -15,6 +15,7 @@ extraResources:
       - runner.mjs.map
       - runner-config.mjs
       - runner-config.mjs.map
+      - hermes_worker.py
       - release.json
       - ui/**/*
 asar: true

+ 1 - 1
package.json

@@ -14,7 +14,7 @@
   },
   "scripts": {
     "dev": "cross-env NODE_ENV=development tsx watch src/server/index.ts",
-    "build": "npm run brand:verify && npm run release:verify && vue-tsc -b tsconfig.client.build.json && vite build && tsc -p tsconfig.server.json",
+    "build": "npm run brand:verify && npm run release:verify && vue-tsc -b tsconfig.client.build.json && vite build && tsc -p tsconfig.server.json && node scripts/copy-worker.mjs",
     "start": "cross-env NODE_ENV=production node dist-server/server/index.js",
     "typecheck": "vue-tsc -b && tsc --noEmit -p tsconfig.server.json",
     "test": "vitest run",

+ 2 - 1
scripts/build-runner.mjs

@@ -1,10 +1,11 @@
 import { build } from 'esbuild'
-import { mkdir } from 'node:fs/promises'
+import { mkdir,copyFile } from 'node:fs/promises'
 import { resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
 
 export async function buildRunner(directory = resolve(import.meta.dirname, '../.runner-build')) {
   await mkdir(directory, { recursive: true })
+  await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))
   await build({
     entryPoints: { runner:resolve(import.meta.dirname, '../src/runner/index.ts'), 'runner-config':resolve(import.meta.dirname, '../src/runner/config.ts') },
     outdir:directory, outExtension:{'.js':'.mjs'}, bundle: true, platform: 'node',

+ 5 - 0
scripts/copy-worker.mjs

@@ -0,0 +1,5 @@
+import {mkdir,copyFile} from 'node:fs/promises'
+import {resolve} from 'node:path'
+const directory=resolve(import.meta.dirname,'../dist-server/runner/worker')
+await mkdir(directory,{recursive:true})
+await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))

+ 14 - 1
src/client/components/app/RunnerSettingsPanel.vue

@@ -8,6 +8,7 @@ const runners = ref<Runner[]>([]), sources = ref<Array<{id:string;name:string}>>
 const busy = ref(false), error = ref(''), notice = ref('')
 const name = ref(''), source = ref('local'), profiles = ref('default')
 const serverURL = ref(window.location.origin), hermesURL = ref('http://127.0.0.1:9119')
+const computers=ref(false),computerRuntime=ref<'docker'|'podman'>('docker'),computerImage=ref(''),computerPython=ref(''),hermesSource=ref(''),hermesHome=ref('')
 const artifactRoots = ref(''), insecure = ref(false), configuration = ref<RunnerConfiguration>()
 
 async function refresh() {
@@ -27,8 +28,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 和执行电脑上的绝对路径')
     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}:{})}
+    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:{runtime:computerRuntime.value,imageId:computerImage.value,python:computerPython.value,hermesSource:hermesSource.value,hermesHome:hermesHome.value}}:{})}
     notice.value = '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。'
     await refresh()
   })
@@ -68,6 +70,16 @@ onMounted(()=>{void action(refresh)})
       <label>Runner 本机 Hermes 地址<input v-model="hermesURL" type="url" required :disabled="busy"></label>
       <label>允许导出的产物目录<textarea v-model="artifactRoots" rows="2" :disabled="busy" placeholder="每行一个绝对路径,留空时禁止导出文件"></textarea></label>
       <label class="runner-check"><input v-model="insecure" type="checkbox" :disabled="busy">使用可信局域网 HTTP</label>
+      <label class="runner-check"><input v-model="computers" type="checkbox" :disabled="busy">启用隔离电脑 Worker</label>
+      <fieldset v-if="computers" class="runner-computer">
+        <legend>执行电脑配置</legend>
+        <label>容器运行时<select v-model="computerRuntime"><option value="docker">Docker</option><option value="podman">Podman</option></select></label>
+        <label>兼容镜像 ID<input v-model.trim="computerImage" required placeholder="sha256:完整镜像 ID"></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>
+      </fieldset>
       <button class="solid-button" :disabled="busy || !name.trim() || !profiles.trim()">注册节点</button>
     </form>
     <div v-else class="runner-setup">
@@ -93,6 +105,7 @@ onMounted(()=>{void action(refresh)})
 .runner-list span,.runner-panel small { color: var(--text-secondary); font-size: 12px; line-height: 1.5; }
 .runner-panel form,.runner-panel label,.runner-setup { display: grid; gap: 8px; min-width: 0; }
 .runner-panel form { gap: 14px; }
+.runner-computer { display: grid; gap: 12px; border: 1px solid var(--line); border-radius: 8px; padding: 12px; min-width: 0; }
 .runner-panel input:not([type=checkbox]),.runner-panel select,.runner-panel textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-raised); color: var(--text-primary); font: inherit; }
 .runner-panel .runner-check { display: flex; align-items: center; }
 .runner-panel code { overflow-wrap: anywhere; line-height: 1.7; }

+ 15 - 5
src/client/views/ConversationsView.vue

@@ -141,6 +141,7 @@ const form = reactive({
   name: '',
   avatar: '',
   instructions: '',
+  execution: 'profile' as 'profile'|'computer',
   canManageTeam: false,
   source: '',
   memberIds: [] as string[],
@@ -363,7 +364,8 @@ async function openDialog(kind: NonNullable<typeof dialog.value>) {
     name: '',
     avatar: kind === 'agent' ? encodeAgentAvatar(randomAgentIdentity('new-agent', 'Agent')) : '',
     instructions: '',
-    canManageTeam: false,
+    execution: 'profile' as 'profile'|'computer',
+  canManageTeam: false,
     source: '',
     memberIds: [],
     memberRoles: {},
@@ -420,7 +422,7 @@ async function save() {
   busy.value = true
   error.value = ''
   try {
-    const fields = { name: form.name, ...(isAgentDialog.value ? { avatar: form.avatar, canManageTeam: form.canManageTeam } : {}), instructions: form.instructions }
+    const fields = { name: form.name, ...(isAgentDialog.value ? { avatar: form.avatar, canManageTeam: form.canManageTeam, execution:form.execution } : {}), instructions: form.instructions }
     if (dialog.value === 'agent') {
       const [nodeId, profile] = JSON.parse(form.source)
       const result = await apiRequest<{ agent: Agent }>('/api/app/agents', {
@@ -686,7 +688,7 @@ onBeforeUnmount(() => {
     <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
     <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
     <RemoteAgentPicker v-if="remotePickerOpen && !auth.isBotOnly" @close="remotePickerOpen = false" @added="remoteAdded" />
-    <dialog v-if="dialog" ref="dialogElement" class="editor" @cancel.prevent="closeDialog">
+    <Teleport to="body"><dialog v-if="dialog" ref="dialogElement" class="editor" @cancel.prevent="closeDialog">
       <form @submit.prevent="save">
         <header>
           <h2>
@@ -730,9 +732,17 @@ onBeforeUnmount(() => {
             "
           />
         </label>
+        <label v-if="isAgentDialog && !isRemoteAgent">
+          执行环境
+          <select v-model="form.execution" aria-label="执行环境">
+            <option value="profile">基础 Profile 环境</option>
+            <option value="computer">隔离电脑</option>
+          </select>
+          <small v-if="form.execution === 'computer'">文件、终端和桌面操作在独立电脑中执行。需先配置支持电脑的 Runner,并为基础 Profile 设置绝对工作目录。目前电脑不联网。</small>
+        </label>
         <label v-if="isAgentDialog && !isRemoteAgent" class="team-management-permission">
           <span><input v-model="form.canManageTeam" type="checkbox" aria-label="允许组建团队" aria-describedby="team-management-help" />允许组建团队</span>
-          <small id="team-management-help">允许自主创建成员、组建团队并启动任务。仅使用当前账号获准的基础 Agent,新成员默认不获得此权限。发起者需使用与 Web 同机且已启用工具桥的基础 Agent。</small>
+          <small id="team-management-help">允许自主创建成员、组建团队并启动任务。仅使用当前账号获准的基础 Agent,新成员默认不获得此权限。发起者需使用已启用工具桥的同机 Hermes,或支持团队工具的 Runner。隔离发起者新建的成员也使用隔离电脑。</small>
         </label>
         <button v-if="!isAgentDialog && !auth.isBotOnly" type="button" @click="remotePickerOpen = true">添加远程 Agent</button>
         <fieldset v-if="selectedPreset" class="preset-role-mapping">
@@ -810,7 +820,7 @@ onBeforeUnmount(() => {
           </button>
         </footer>
       </form>
-    </dialog>
+    </dialog></Teleport>
   </WorkspaceShell>
 </template>
 <style scoped>

+ 45 - 9
src/runner/agent.ts

@@ -1,7 +1,9 @@
 import { randomUUID, createHash } from 'node:crypto'
 import { DatabaseSync } from 'node:sqlite'
-import { realpath } from 'node:fs/promises'
+import { realpath,access } from 'node:fs/promises'
 import { resolve, sep, join } from 'node:path'
+import {ComputerError} from './computers/container.js'
+import {ComputerRuntime,ComputerGateway,type ComputerTarget} from './worker/gateway.js'
 import { HttpError } from '../server/errors.js'
 import { UpstreamClient } from '../server/upstream.js'
 import { UpstreamServiceSession } from '../server/localAuth.js'
@@ -11,7 +13,7 @@ import type { RunnerCommand, RunnerConfiguration } from '../shared/runner.js'
 import { LoopbackTransport, isLocalAuthorizationTarget } from '../server/loopbackAuthorization.js'
 
 const commands=new Set(['session.create','session.resume','session.close','session.usage','session.interrupt','session.cwd.set','prompt.submit','session.steer','image.attach_bytes','file.attach','approval.respond','clarify.respond'])
-interface Connection {gateway:WorkspaceGateway;sessions:Map<string,string>;running:Set<string>;events:Promise<void>}
+interface Connection {cleanupOnly?:boolean;gateway:WorkspaceGateway|ComputerGateway;sessions:Map<string,string>;running:Set<string>;events:Promise<void>}
 interface Lease {connectionId:string;profile:string;lease:WorkspaceToolLease;controller:AbortController;session:{runtimeId:string;storedId:string}}
 
 export class RunnerAgent {
@@ -26,6 +28,7 @@ export class RunnerAgent {
   private closedLeases=new Map<string,number>()
   private inflight=new Map<string,Promise<unknown>>()
   private db:DatabaseSync
+  private computers?:ComputerRuntime
   private target:GatewayTarget
   private controlTransport=new LoopbackTransport()
   private serverEpoch?:string
@@ -39,6 +42,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)
     this.compactReceipts()
   }
   private async api(path:string,body?:unknown):Promise<any> {
@@ -46,7 +50,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',...(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':'',...(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')
@@ -66,7 +70,7 @@ export class RunnerAgent {
     this.connections.delete(id)
     for(const [leaseId,lease] of this.leases)if(lease.connectionId===id){this.closedLeases.set(leaseId,Date.now()+60000);lease.controller.abort();this.leases.delete(leaseId);void lease.lease.dispose()}
     if(interrupt){let timer:ReturnType<typeof setTimeout>|undefined;try{await Promise.race([Promise.all([...connection.running].map(session_id=>connection.gateway.rpc('session.interrupt',{session_id}).catch(()=>{}))),new Promise(resolve=>{timer=setTimeout(resolve,3000)})])}finally{clearTimeout(timer)}}
-    connection.gateway.close()
+    await connection.gateway.close()
   }
   private disconnect():Promise<void> {
     if(this.disconnecting)return this.disconnecting
@@ -84,6 +88,19 @@ export class RunnerAgent {
     if(!this.active||!this.connected)throw new Error('执行节点已断开')
     if(command.kind==='http') {
       const path=String(p.path),search=new URLSearchParams(String(p.search??''))
+      if(path==='/api/computer/capabilities'||(p.computer&&path==='/api/plugins/yaoyao-bot-bridge/capabilities')){
+        this.requireProfile(search.get('profile')??'default')
+        if(p.method!=='GET'||!this.computers)throw new HttpError(409,'执行节点未配置隔离电脑','computer_unavailable')
+        await Promise.all([access(this.computers.config.python),access(this.computers.config.hermesSource),access(this.computers.script)])
+        return {status:200,headers:{},body:Buffer.from(JSON.stringify({version:1,ready:true,native_tools:true,in_process:true,network:'none'})).toString('base64')}
+      }
+      if(p.computer&&path==='/api/files/download')throw new HttpError(409,'隔离电脑产物需通过产物导出接口读取','computer_artifact_required')
+      if(p.computer&&/^\/api\/sessions\/[^/]+\/messages$/.test(path)){
+        if(p.method!=='GET'||!this.computers)throw new HttpError(403,'电脑历史不可用','computer_unavailable')
+        const profile=this.requireProfile(search.get('profile')??'default'),session=this.computers.session(decodeURIComponent(path.split('/')[3]!),p.computer as ComputerTarget,profile)
+        const offset=Math.max(0,Number(search.get('offset')??0)),history=session.history
+        return {status:200,headers:{},body:Buffer.from(JSON.stringify({messages:history.slice(Math.max(0,history.length-offset-500),history.length-offset)})).toString('base64')}
+      }
       if(p.method!=='GET'||!(/^\/api\/(?:status|profiles|config)$/.test(path)||/^\/api\/sessions\/[^/]+\/messages$/.test(path)||path==='/api/plugins/yaoyao-bot-bridge/capabilities'||path==='/api/files/download'))throw new HttpError(403,'Runner 不支持此 HTTP 操作','runner_http_forbidden')
       if(!['/api/status','/api/profiles'].includes(path))this.requireProfile(search.get('profile')??'default')
       if(path==='/api/files/download') {
@@ -97,8 +114,13 @@ export class RunnerAgent {
     const connectionId=String(p.connectionId??'')
     if(command.kind==='gateway.open') {
       if(this.closedConnections.has(connectionId)||this.connections.has(connectionId)||!/^[0-9a-f-]{36}$/.test(connectionId)||this.connections.size>=64)throw new Error('执行通道无效或已达上限')
-      const gateway=new WorkspaceGateway(this.target)
-      const connection:Connection={gateway,sessions:new Map(),running:new Set(),events:Promise.resolve()}
+      let gateway:WorkspaceGateway|ComputerGateway
+      if(p.computer){
+        const meta=p.computer as ComputerTarget
+        if(!this.computers||![meta.environmentId,meta.agentId,String(p.workId)].every(value=>/^[0-9a-f-]{36}$/.test(value))||!/^[a-f0-9]{64}$/.test(meta.ownerKey))throw new HttpError(409,'电脑任务配置或授权无效','computer_unavailable')
+        gateway=new ComputerGateway(this.computers,meta,String(p.workId),async()=>{if(!this.active||!this.connected||(await this.api('check',{connectionId})).allowed!==true)throw new HttpError(403,'电脑任务授权已失效','computer_authorization_revoked')},body=>this.api('artifact',{connectionId,...body}))
+      }else gateway=new WorkspaceGateway(this.target)
+      const connection:Connection={cleanupOnly:p.cleanupOnly===true,gateway,sessions:new Map(),running:new Set(),events:Promise.resolve()}
       this.connections.set(connectionId,connection)
       gateway.onEvent=(frame:GatewayFrame)=>{
         if(frame.session_id&&!connection.sessions.has(frame.session_id))return
@@ -113,7 +135,8 @@ export class RunnerAgent {
     if(command.kind==='gateway.rpc') {
       const connection=this.connections.get(connectionId),method=String(p.method),params=p.params as Record<string,unknown>
       if(!connection||!commands.has(method)||!params||typeof params!=='object')throw new HttpError(403,'执行通道或命令无效','runner_command_forbidden')
-      if(method==='session.create'||method==='session.resume')this.requireProfile(params.profile)
+      if(connection.cleanupOnly&&!['session.resume','session.interrupt','session.close'].includes(method))throw new HttpError(403,'清理通道不允许执行新工作','runner_cleanup_forbidden')
+      if(method==='session.create'||method==='session.resume'){if(!connection.cleanupOnly)this.requireProfile(params.profile)}
       else if(!connection.sessions.has(String(params.session_id)))throw new HttpError(403,'会话不属于这个通道','runner_session_forbidden')
       if(method==='prompt.submit')connection.running.add(String(params.session_id))
       const result=await connection.gateway.rpc(method,params)
@@ -132,6 +155,12 @@ export class RunnerAgent {
       const source=[...this.connections].find(([,c])=>c.sessions.get(session?.runtimeId)===p.profile)
       if(!source)throw new HttpError(403,'工具会话不属于此节点','runner_session_forbidden')
       const controller=new AbortController()
+      const virtual=source[1].gateway
+      if(virtual instanceof ComputerGateway){
+        virtual.installTeamLease(leaseId,p.catalog as any[],async(toolId,args,callId)=>(await this.api('tool',{leaseId,toolId,arguments:args,callId})).result)
+        const lease={bind:async()=>{},dispose:async()=>{virtual.removeTeamLease(leaseId)}}
+        this.leases.set(leaseId,{lease,controller,session,connectionId:source[0],profile:String(p.profile)});return {ok:true}
+      }
       const lease=await createWorkspaceToolLease({target:this.target,profile:String(p.profile),workId:String(p.workId),session:()=>session,signal:controller.signal,
         assertActive:()=>{if(!this.connected||controller.signal.aborted)throw new Error('工具授权已失效')},
         catalog:()=>p.catalog as any[],call:async(toolId,args,callId)=>(await this.api('tool',{leaseId,toolId,arguments:args,callId:callId??randomUUID()})).result,
@@ -176,7 +205,7 @@ export class RunnerAgent {
     const pending=Promise.resolve().then(async()=>{
       if(command.expiresAt<=Date.now()||!(await this.api('admit',{id:command.id})).allowed||command.expiresAt<=Date.now())throw new HttpError(403,'命令授权已经失效,未执行','runner_command_not_admitted')
       return this.execute(command)
-    }).then(result=>({result}),error=>({error:{message:error instanceof HttpError?error.message:'执行节点操作失败',code:error instanceof HttpError?error.code:'runner_error'}}))
+    }).then(result=>({result}),error=>({error:{message:error instanceof HttpError||error instanceof ComputerError?error.message:'执行节点操作失败',code:error instanceof HttpError||error instanceof ComputerError?error.code:'runner_error'}}))
       .then(reply=>{this.db.prepare("UPDATE commands SET state='complete',result=? WHERE id=?").run(JSON.stringify(reply),command.id);return reply})
       .finally(()=>this.inflight.delete(command.id))
     this.inflight.set(command.id,pending);return pending
@@ -211,6 +240,13 @@ export class RunnerAgent {
           if(this.active)await new Promise(resolve=>setTimeout(resolve,1000))
         }
       }
-    }finally{signal.removeEventListener('abort',abort);await this.disconnect();await Promise.allSettled(deliveries.values());await Promise.allSettled(this.inflight.values());this.target.client.close();this.controlTransport.close();this.db.close();this.status('执行节点已停止')}
+    }finally{
+      signal.removeEventListener('abort',abort)
+      try{
+        await this.disconnect().catch(()=>{})
+        await Promise.allSettled(deliveries.values());await Promise.allSettled(this.inflight.values())
+        await this.computers?.pool.close()
+      }finally{this.target.client.close();this.controlTransport.close();this.db.close();this.status('执行节点已停止')}
+    }
   }
 }

+ 20 - 11
src/runner/computers/container.ts

@@ -1,8 +1,7 @@
 import { createHash, randomBytes } from 'node:crypto'
-import { execFile } from 'node:child_process'
-import { promisify } from 'node:util'
+import { spawn } from 'node:child_process'
 import { mkdir, realpath, chmod } from 'node:fs/promises'
-import { join, resolve } from 'node:path'
+import { join, resolve, posix } from 'node:path'
 import { z } from 'zod'
 
 export const COMPUTER_WORKSPACE = '/home/cua/workspace'
@@ -10,14 +9,23 @@ export const CUA_DRIVER = '/usr/local/libexec/openmausbot/cua-driver'
 export const CUA_SOCKET = '/run/user/1000/openmausbot-cua.sock'
 const managedLabel='cn.samien.yaoyao.computer', runnerLabel='cn.samien.yaoyao.runner', specLabel='cn.samien.yaoyao.computer-spec', ownerLabel='cn.samien.yaoyao.computer-owner'
 const mib=1024*1024
-const execAsync=promisify(execFile)
 export interface CommandResult {stdout:string;stderr:string}
-export type ContainerCommand=(runtime:'docker'|'podman',args:string[],options?:{timeout?:number;signal?:AbortSignal})=>Promise<CommandResult>
-const command:ContainerCommand=async(runtime,args,options={})=>execAsync(runtime,args,{encoding:'utf8',maxBuffer:8*mib,timeout:options.timeout??15000,signal:options.signal})
+export type ContainerCommand=(runtime:'docker'|'podman',args:string[],options?:{timeout?:number;signal?:AbortSignal;input?:Buffer})=>Promise<CommandResult>
+const command:ContainerCommand=(runtime,args,options={})=>new Promise((resolveResult,reject)=>{
+  const child=spawn(runtime,args,{stdio:'pipe',signal:options.signal}),out:Buffer[]=[],err:Buffer[]=[]
+  let bytes=0,failure:Error|undefined
+  const timer=setTimeout(()=>{failure=Object.assign(new Error('电脑命令超时'),{killed:true});child.kill('SIGTERM')},options.timeout??15000)
+  const collect=(target:Buffer[],chunk:Buffer)=>{bytes+=chunk.length;if(bytes>8*mib){failure=Object.assign(new Error('电脑输出超过限制'),{killed:true});child.kill('SIGTERM')}else target.push(chunk)}
+  child.stdout.on('data',chunk=>collect(out,Buffer.from(chunk)));child.stderr.on('data',chunk=>collect(err,Buffer.from(chunk)))
+  child.once('error',error=>{failure=error});child.stdin.on('error',()=>{})
+  child.once('close',code=>{clearTimeout(timer);const result={stdout:Buffer.concat(out).toString(),stderr:Buffer.concat(err).toString()};if(failure||code!==0)reject(Object.assign(failure??new Error('电脑命令失败'),{...result,...(!failure?{code}:{} )}));else resolveResult(result)})
+  child.stdin.end(options.input)
+})
 const schema=z.object({
   id:z.string().uuid(),ownerKey:z.string().min(1).max(256),
   imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),
   cpus:z.number().int().min(1).max(8).default(2),memoryMiB:z.number().int().min(1024).max(16384).default(4096),
+  cwd:z.string().max(4096).refine(value=>!/[\u0000-\u001f,]/.test(value)&&posix.isAbsolute(value)&&posix.normalize(value)===value&&value!=='/'&&!/^\/(?:proc|sys|dev|run|tmp|etc|usr|bin|sbin)(?:\/|$)/.test(value)&&value!=='/home/cua','电脑工作目录不受支持').default(COMPUTER_WORKSPACE),
   pids:z.number().int().min(64).max(1024).default(512),
 }).strict()
 export type ComputerSpecification=z.input<typeof schema>
@@ -30,7 +38,7 @@ export interface ComputerProvider {
   ensure(spec:ComputerSpecification,authorize:()=>void):Promise<ComputerState>
   stop(spec:ComputerSpecification):Promise<void>
   remove(spec:ComputerSpecification):Promise<void>
-  execute(spec:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number}):Promise<CommandResult>
+  execute(spec:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer}):Promise<CommandResult>
   health(spec:ComputerSpecification,authorize:()=>void):Promise<unknown>
 }
 export class ComputerError extends Error { constructor(readonly code:string,message:string){super(message)} }
@@ -96,13 +104,14 @@ export class ContainerComputerProvider implements ComputerProvider {
     const nanoCPUs=host.NanoCpus??(host.CpuPeriod>0?host.CpuQuota/host.CpuPeriod*1e9:0)
     const noPrivilege=(host.SecurityOpt??[]).some((value:string)=>/^no-new-privileges(?::true)?$/.test(value))
     const ports=Object.entries(host.PortBindings??{}),mounts=detail.Mounts??[]
+    const destinations=spec.cwd===COMPUTER_WORKSPACE?[COMPUTER_WORKSPACE]:[COMPUTER_WORKSPACE,spec.cwd]
     if(detail.Config?.Labels?.[specLabel]!==digest(spec)||detail.Image!==spec.imageId||host.Privileged!==false||host.Memory!==spec.memoryMiB*mib||host.MemorySwap!==spec.memoryMiB*mib
       ||nanoCPUs!==spec.cpus*1e9||host.PidsLimit!==spec.pids||host.IpcMode!=='private'||host.CgroupnsMode!=='private'
       ||host.NetworkMode!=='none'||host.PidMode==='host'||host.UsernsMode==='host'||host.UTSMode==='host'
       ||!noPrivilege||(host.SecurityOpt??[]).some((value:string)=>/unconfined/.test(value))
       ||JSON.stringify(capabilities(host.CapAdd))!==JSON.stringify(expectedCaps)||!capabilities(host.CapDrop).includes('ALL')
       ||(host.Devices??[]).length||(host.DeviceRequests??[]).length||host.RestartPolicy?.Name!=='no'
-      ||mounts.length!==1||mounts[0].Type!=='bind'||mounts[0].Source!==workspace||mounts[0].Destination!==COMPUTER_WORKSPACE||mounts[0].RW!==true
+      ||mounts.length!==destinations.length||mounts.some((mount:any)=>mount.Type!=='bind'||mount.Source!==workspace||!destinations.includes(mount.Destination)||mount.RW!==true)||new Set(mounts.map((mount:any)=>mount.Destination)).size!==destinations.length
       ||ports.length!==0)
       throw new ComputerError('computer_configuration_unsafe','电脑实例的隔离、资源或数据目录不符合要求,请先停止并核对')
   }
@@ -124,7 +133,7 @@ export class ContainerComputerProvider implements ComputerProvider {
       '--memory',`${spec.memoryMiB}m`,'--memory-swap',`${spec.memoryMiB}m`,'--cpus',String(spec.cpus),'--pids-limit',String(spec.pids),
       '--shm-size','512m','--security-opt','no-new-privileges:true','--cap-drop','ALL','--cap-add','SETUID','--cap-add','SETGID',
       ...(this.runtime==='podman'?['--userns','keep-id:uid=1000,gid=1000','--user','root','--cap-add','SYS_CHROOT']:[]),
-      '--mount',`type=bind,source=${workspace},target=${COMPUTER_WORKSPACE}${this.runtime==='podman'?',relabel=private':''}`,
+      ...[...new Set([COMPUTER_WORKSPACE,spec.cwd])].flatMap(destination=>['--mount',`type=bind,source=${workspace},target=${destination}${this.runtime==='podman'?',relabel=private':''}`]),
       '--env',`VNC_PW=${randomBytes(24).toString('base64url')}`,spec.imageId]
   }
   async ensure(value:ComputerSpecification,authorize:()=>void):Promise<ComputerState> {
@@ -175,7 +184,7 @@ export class ContainerComputerProvider implements ComputerProvider {
       // Persistent workspace deletion is a separate, explicit operation.
     })
   }
-  async execute(value:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number}):Promise<CommandResult> {
+  async execute(value:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer}):Promise<CommandResult> {
     const spec=this.validateSpecification(value)
     if(!argv.length||argv.length>64||argv.some(arg=>typeof arg!=='string'||arg.includes('\0'))||argv.join('').length>65536)throw new ComputerError('computer_command_invalid','电脑命令无效或过长')
     return this.serial(spec,async()=>{
@@ -184,7 +193,7 @@ export class ContainerComputerProvider implements ComputerProvider {
       if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
       options.authorize()
       try {
-        const result=await this.run(this.runtime,['exec','--user','1000:1000','--workdir',COMPUTER_WORKSPACE,'--',state.containerId,...argv],{timeout:Math.min(60000,Math.max(1000,options.timeout??30000)),signal:options.signal})
+        const result=await this.run(this.runtime,['exec',...(options.input?['-i']:[]),'--user','1000:1000','--workdir',spec.cwd,'--',state.containerId,...argv],{timeout:Math.min(60000,Math.max(1000,options.timeout??30000)),signal:options.signal,input:options.input})
         options.authorize();return result
       }catch(error){
         // Killing a docker exec client alone cannot prove its guest process stopped.

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

@@ -70,6 +70,19 @@ export class ComputerPool {
       }catch(error){await this.stopEntry(entry).catch(()=>{});throw error}
     })
   }
+  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','电脑资源正在恢复或关闭')
+      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
+      if(entry.status!=='free')throw new ComputerError('computer_busy','请先释放当前电脑,再应用新的配置')
+      entry.status='stopping';entry.generation++;this.save(entry)
+      try{await this.provider.remove(entry.spec);authorize();entry.spec=spec;entry.status='free';this.save(entry)}
+      catch(error){entry.status='uncertain';this.save(entry);throw error}
+    })
+  }
   renew(lease:ComputerLease):void{this.assert(lease);const entry=this.require(lease);entry.expiresAt=this.now()+this.limits.ttlMs;this.save(entry)}
   use<T>(lease:ComputerLease,action:(context:{signal:AbortSignal;authorize():void})=>Promise<T>):Promise<T>{
     this.assert(lease)
@@ -79,10 +92,17 @@ export class ComputerPool {
     return pending
   }
   release(lease:ComputerLease):Promise<void>{
-    const entry=this.require(lease,false)
+    const existing=this.get(lease.environmentId)
+    const entry=existing&&existing.lease?.id===lease.id&&existing.spec.ownerKey===lease.ownerKey&&existing.lease.holderId===lease.holderId&&['stopping','uncertain'].includes(existing.status)?existing:this.require(lease,false)
     this.invalidate(entry)
     return this.serial(entry.spec.id,async()=>{const current=this.get(entry.spec.id)!;if(current.status==='free')return;await this.stopEntry(current)})
   }
+  async stopHolder(ownerKey:string,id:string,holderId:string):Promise<void>{
+    const entry=this.get(id)
+    if(!entry||entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
+    if(entry.lease?.holderId!==holderId)return
+    this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry))
+  }
   private invalidate(expected:Entry){
     const entry=this.get(expected.spec.id)
     if(!entry||entry.lease?.id!==expected.lease?.id||entry.status==='free')return
@@ -116,5 +136,6 @@ export class ComputerPool {
     this.closing=true
     for(const entry of this.rows())if(entry.status!=='free')this.invalidate(entry)
     await Promise.allSettled(this.rows().filter(entry=>entry.status!=='free').map(entry=>this.serial(entry.spec.id,()=>this.stopEntry(this.get(entry.spec.id)!))))
+    if(this.rows().some(entry=>entry.status!=='free'))throw new ComputerError('computer_stop_uncertain','尚未确认所有电脑环境已停止')
   }
 }

+ 1 - 0
src/runner/config.ts

@@ -7,6 +7,7 @@ const schema=z.object({
   hermesURL:z.string().url().default('http://127.0.0.1:9119'),
   allowedProfiles:z.array(z.string().trim().min(1).max(256).regex(/^[^/\\\u0000-\u001f]+$/)).min(1).max(256),
   artifactRoots:z.array(z.string().refine(isAbsolute,'产物目录必须为绝对路径')).max(64).default([]),
+  computers:z.object({runtime:z.enum(['docker','podman']),imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),python:z.string().refine(isAbsolute),hermesSource:z.string().refine(isAbsolute),hermesHome:z.string().refine(isAbsolute),maxConcurrent:z.number().int().min(1).max(8).optional()}).strict().optional(),
   allowInsecureLan:z.boolean().optional(),hermesCredentials:z.object({username:z.string().max(256),password:z.string().max(4096)}).strict().optional(),
 }).strict()
 export function parseRunnerConfiguration(value:unknown):RunnerConfiguration {

+ 1 - 1
src/runner/index.ts

@@ -44,7 +44,7 @@ try {
   })
   try{await agent.run(controller.signal)}finally{instance.release()}
 }catch(error){
-  if(parent)parent.postMessage({type:'error',message:'执行节点启动失败,请检查配置或重新导入'})
+  if(parent)parent.postMessage({type:'error',message:(error as {code?:string})?.code==='computer_stop_uncertain'?'尚未确认所有电脑环境已停止,请核对容器运行时':'执行节点启动或运行失败,请检查配置'})
   else process.stderr.write(`${error instanceof Error?error.message:'执行节点启动失败'}\n`)
   process.exitCode=1
 }

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

@@ -0,0 +1,219 @@
+import {randomUUID} from 'node:crypto'
+import {join,dirname,posix} from 'node:path'
+import {fileURLToPath} from 'node:url'
+import {mkdir} from 'node:fs/promises'
+import type {DatabaseSync} from 'node:sqlite'
+import {z} from 'zod'
+import {HttpError} from '../../server/errors.js'
+import type {GatewayFrame} from '../../server/workspaceGateway.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'
+import {HermesWorkerProcess,type WorkerModel,type WorkerTool,type WorkerFrame} from './process.js'
+
+export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string}
+export interface WorkerSession {id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain'}
+interface Live {session:WorkerSession;model:WorkerModel;lease:ComputerLease;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
+const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
+const text={type:'string'}
+const TOOLS:WorkerTool[]=[
+  {name:'computer_export',description:'将隔离电脑中的文件作为可下载附件回传当前会话,最大 25 MiB。',inputSchema:object({path:text,name:text},['path'])},
+  {name:'computer_shell',description:'在当前隔离电脑和 Profile 工作目录执行 shell 命令。',inputSchema:object({command:text},['command'])},
+  {name:'computer_read_file',description:'读取隔离电脑内的 UTF-8 文本文件。',inputSchema:object({path:text},['path'])},
+  {name:'computer_write_file',description:'向隔离电脑写入 UTF-8 文本文件。',inputSchema:object({path:text,content:text},['path','content'])},
+  {name:'computer_desktop_state',description:'查看隔离电脑当前桌面,返回真实截图。',inputSchema:object({},[])},
+  {name:'computer_action',description:'在隔离电脑内调用 CUA 动作;先查看桌面并使用可用动作。',inputSchema:object({name:text,arguments:{type:'object'}},['name','arguments'])},
+]
+const pathField=z.string().min(1).max(4096).refine(value=>!value.includes('\0'))
+export class ComputerRuntime {
+  readonly provider:ContainerComputerProvider
+  readonly pool:ComputerPool
+  readonly ready:Promise<void>
+  readonly config:NonNullable<RunnerConfiguration['computers']>
+  readonly script:string
+  constructor(readonly db:DatabaseSync,config:RunnerConfiguration,readonly home:string,script?:string){
+    if(!config.computers)throw new HttpError(409,'执行节点尚未配置隔离电脑','computer_unavailable')
+    this.config=config.computers
+    this.script=script??join(dirname(fileURLToPath(import.meta.url)),'hermes_worker.py')
+    this.provider=new ContainerComputerProvider(this.config.runtime,config.runnerId,home)
+    this.pool=new ComputerPool(db,this.provider,{concurrent:this.config.maxConcurrent??2,environments:32,ttlMs:30000})
+    db.exec('CREATE TABLE IF NOT EXISTS computer_sessions(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
+    this.ready=this.pool.recover();void this.ready.catch(()=>{})
+  }
+  save(session:WorkerSession){this.db.prepare('INSERT INTO computer_sessions VALUES(?,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value').run(session.id,JSON.stringify(session))}
+  session(id:string,meta:ComputerTarget,profile:string):WorkerSession {
+    const row=this.db.prepare('SELECT value FROM computer_sessions WHERE id=?').get(id) as {value:string}|undefined
+    const session:WorkerSession|undefined=row?JSON.parse(row.value):undefined
+    if(!session||session.ownerKey!==meta.ownerKey||session.environmentId!==meta.environmentId||session.profile!==profile)throw new HttpError(404,'电脑会话不存在或不属于当前任务','computer_session_missing')
+    return session
+  }
+  async resolve(profile:string,signal?:AbortSignal){
+    const resolver=new HermesWorkerProcess(this.config.python,this.script,{mode:'resolve',profile,hermesSource:this.config.hermesSource,hermesHome:this.config.hermesHome})
+    const abort=()=>{void resolver.close()};signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort()
+    try{return await resolver.wait('resolved')}finally{signal?.removeEventListener('abort',abort);await resolver.close()}
+  }
+}
+export class ComputerGateway {
+  onEvent:(frame:GatewayFrame)=>void=()=>{}
+  onDisconnect:()=>void=()=>{}
+  private live?:Live
+  private recoverySession?:WorkerSession
+  private closed=false
+  private controller=new AbortController()
+  private timer?:ReturnType<typeof setInterval>
+  private checking=false
+  private team?:{id:string;catalog:WorkerTool[];call(name:string,args:Record<string,unknown>,callId:string):Promise<unknown>}
+  constructor(readonly runtime:ComputerRuntime,readonly meta:ComputerTarget,readonly workId:string,readonly check:()=>Promise<void>,readonly publish:(body:Record<string,unknown>)=>Promise<any>){ }
+  async connect(){await this.runtime.ready;await this.authorized()}
+  private async authorized(){try{await this.check();this.guard()}catch(error){await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect();throw error}}
+  private guard(){if(this.closed||this.live?.controller.signal.aborted)throw new HttpError(410,'电脑任务授权已结束','computer_cancelled')}
+  private event(type:string,payload:Record<string,unknown>){if(!this.closed&&this.live)this.onEvent({type,session_id:this.live.session.id,payload})}
+  private spec(session:WorkerSession):ComputerSpecification{return {id:this.meta.environmentId,ownerKey:this.meta.ownerKey,imageId:this.runtime.config.imageId,cwd:session.cwd}}
+  async rpc(method:string,params:Record<string,any>):Promise<any>{
+    if((method==='session.interrupt'||method==='session.close')&&params.session_id===this.live?.session.id){await this.interrupt();return {status:'interrupted'}}
+    if((method==='session.interrupt'||method==='session.close')&&params.session_id===this.recoverySession?.id){await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {status:'interrupted'}}
+    this.guard()
+    if(method==='session.create'||method==='session.resume'){
+      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}}}
+      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(),ownerKey:this.meta.ownerKey,environmentId:this.meta.environmentId,profile,cwd:resolved.cwd,configuredCwd:resolved.configuredCwd,history:[]}
+      session.cwd=resolved.cwd;session.configuredCwd=resolved.configuredCwd
+      await this.runtime.pool.configure(this.spec(session),()=>this.guard())
+      await this.authorized()
+      const controller=new AbortController(),lease=await this.runtime.pool.acquire(this.spec(session),this.workId,()=>{this.guard();if(controller.signal.aborted)throw new Error('cancelled')},controller.signal)
+      if(this.closed){controller.abort();await this.runtime.pool.release(lease);throw new Error('电脑通道已关闭')}
+      this.live={session,lease,controller,model:resolved.model as WorkerModel,images:[],running:false,received:false}
+      this.runtime.save(session)
+      this.timer=setInterval(()=>{if(this.checking)return;this.checking=true;void this.authorized().then(()=>this.runtime.pool.renew(lease)).catch(()=>this.interrupt()).finally(()=>{this.checking=false})},5000);this.timer.unref()
+      return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:profile}}
+    }
+    const live=this.live
+    if(!live||params.session_id!==live.session.id)throw new HttpError(403,'电脑会话不属于当前通道','computer_session_forbidden')
+    if(method==='session.interrupt'||method==='session.close'){await this.interrupt();return {status:'interrupted'}}
+    if(method==='session.usage')return {}
+    if(method==='session.cwd.set'){
+      if(params.cwd!==live.session.cwd&&params.cwd!==live.session.configuredCwd)throw new HttpError(403,'工作目录必须采用基础 Profile 的配置','computer_cwd_forbidden')
+      return {cwd:live.session.cwd}
+    }
+    if(method==='file.attach'||method==='image.attach_bytes'){
+      if(live.running)throw new HttpError(409,'当前轮次已开始,不能追加附件','computer_session_busy')
+      await this.authorized()
+      const name=String(params.name??params.filename??'attachment').replace(/[\/\\\u0000-\u001f]/g,'_').slice(0,200)||'attachment'
+      const encoded=method==='image.attach_bytes'?String(params.content_base64??''):String(params.data_url??'').split(',')[1]??''
+      const bytes=Buffer.from(encoded,'base64');if(bytes.length>25*1024*1024)throw new HttpError(413,'附件超过 25 MiB','attachment_too_large')
+      const relative=`.yaoyao-inputs/${randomUUID()}/${name}`
+      await this.runtime.pool.use(live.lease,context=>this.runtime.provider.execute(this.spec(live.session),['python3','-c','import sys,pathlib; p=pathlib.Path(sys.argv[1]); p.parent.mkdir(parents=True,exist_ok=True); p.write_bytes(sys.stdin.buffer.read())',relative],{...context,input:bytes}))
+      if(method==='image.attach_bytes'){
+        const mime=String(params.mime_type??'image/png')
+        if(!/^image\/(?:png|jpeg|webp|gif)$/.test(mime))throw new HttpError(400,'图片格式无效','invalid_image')
+        if(JSON.stringify(live.images).length+encoded.length>64*1024*1024)throw new HttpError(413,'本轮图片总量超过限制','image_limit')
+        live.images.push({type:'image_url',image_url:{url:`data:${mime};base64,${encoded}`}})
+      }
+      return {ref_text:`[附件 ${name}](${live.session.cwd}/${relative})`}
+    }
+    if(method==='prompt.submit'){
+      if(live.running||live.received)throw new HttpError(409,'电脑会话已经提交过本轮','computer_session_busy')
+      await this.authorized();this.guard()
+      const prompt=String(params.text??''),history=structuredClone(live.session.history)
+      live.running=true;live.session.outcome='uncertain';live.session.history.push({role:'user',content:prompt});this.runtime.save(live.session)
+      const directory=join(this.runtime.home,'workers',live.session.id,this.workId)
+      await mkdir(directory,{recursive:true,mode:0o700})
+      const worker=new HermesWorkerProcess(this.runtime.config.python,this.runtime.script,{mode:'run',home:directory,hermesSource:this.runtime.config.hermesSource,model:live.model,sessionId:live.session.id,taskId:this.workId,cwd:live.session.cwd,tools:[...TOOLS,...(this.team?.catalog??[])],prompt:live.images.length?[{type:'text',text:prompt},...live.images]:prompt,history})
+      live.worker=worker
+      worker.onTool=(name,args,id)=>this.tool(live,name,args,id)
+      worker.onEvent=frame=>{
+        if(frame.type==='delta')this.event('message.delta',{text:frame.text})
+        if(frame.type==='reasoning')this.event('reasoning.delta',{text:frame.text})
+        if(frame.type==='complete'){live.received=true;live.finishing=this.finish(live,frame);void live.finishing.catch(()=>this.lost(live))}
+        if(frame.type==='failed')void this.lost(live)
+      }
+      void worker.exited.then(()=>{if(!live.received&&live.running&&!this.closed)void this.lost(live)})
+      return {status:'streaming'}
+    }
+    throw new HttpError(409,'当前隔离 Worker 不支持此交互','computer_method_unavailable')
+  }
+  installTeamLease(id:string,catalog:WorkerTool[],call:(name:string,args:Record<string,unknown>,callId:string)=>Promise<unknown>){this.team={id,catalog,call}}
+  removeTeamLease(id:string){if(this.team?.id===id)this.team=undefined}
+  private async tool(live:Live,name:string,args:unknown,id:string):Promise<unknown>{
+    await this.authorized();this.guard()
+    this.event('tool.started',{tool_id:id,name})
+    try{
+      const team=this.team?.catalog.find(tool=>tool.name===name)
+      if(team)return await this.team!.call(team.id??name,z.record(z.string(),z.unknown()).parse(args),id)
+      return await this.runtime.pool.use(live.lease,async context=>{
+        const execute=(argv:string[],input?:Buffer)=>this.runtime.provider.execute(this.spec(live.session),argv,{...context,input})
+        if(name==='computer_export'){
+          const body=z.object({path:pathField,name:z.string().min(1).max(240).optional()}).strict().parse(args)
+          const snapshot=`/tmp/yaoyao-export-${randomUUID()}`
+          try{
+            const metadata=JSON.parse((await execute(['python3','-c','import pathlib,sys,shutil,json,hashlib; p=pathlib.Path(sys.argv[1]); assert p.is_file(),"not a file"; assert p.stat().st_size<=25*1024*1024,"file too large"; shutil.copyfile(p,sys.argv[2]); b=pathlib.Path(sys.argv[2]).read_bytes(); assert len(b)<=25*1024*1024,"file too large"; print(json.dumps({"size":len(b),"digest":hashlib.sha256(b).hexdigest()}))',body.path,snapshot])).stdout)
+            await this.publish({phase:'begin',id,name:body.name??posix.basename(body.path),size:metadata.size,digest:metadata.digest})
+            for(let offset=0,index=0;offset<metadata.size;offset+=524288,index++){
+              context.authorize();await this.authorized()
+              const data=(await execute(['python3','-c','import sys,base64; f=open(sys.argv[1],"rb"); f.seek(int(sys.argv[2])); print(base64.b64encode(f.read(524288)).decode())',snapshot,String(offset)])).stdout.trim()
+              await this.publish({phase:'append',id,index,data})
+            }
+            context.authorize();await this.authorized()
+            return (await this.publish({phase:'finish',id})).result
+          }finally{await execute(['rm','-f','--',snapshot]).catch(()=>{})}
+        }
+        if(name==='computer_shell'){
+          const body=z.object({command:z.string().min(1).max(65536)}).strict().parse(args)
+          try{return {exitCode:0,...await execute(['/bin/bash','-lc',body.command])}}catch(error){if(typeof(error as any).code==='number')return {exitCode:(error as any).code,stdout:String((error as any).stdout??''),stderr:String((error as any).stderr??'')};throw error}
+        }
+        if(name==='computer_read_file'){
+          const body=z.object({path:pathField}).strict().parse(args)
+          return execute(['python3','-c','import sys; f=open(sys.argv[1],encoding="utf-8"); s=f.read(1000001); assert len(s)<=1000000,"file too large"; print(s,end="")',body.path])
+        }
+        if(name==='computer_write_file'){
+          const body=z.object({path:pathField,content:z.string().max(1000000)}).strict().parse(args)
+          await execute(['python3','-c','import pathlib,sys; p=pathlib.Path(sys.argv[1]); p.parent.mkdir(parents=True,exist_ok=True); p.write_bytes(sys.stdin.buffer.read())',body.path],Buffer.from(body.content));return {ok:true}
+        }
+        if(name==='computer_action'){
+          const body=z.object({name:z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]{0,99}$/),arguments:z.record(z.string(),z.unknown())}).strict().parse(args)
+          return execute([CUA_DRIVER,'call',body.name,JSON.stringify(body.arguments),'--socket',CUA_SOCKET])
+        }
+        if(name==='computer_desktop_state'){
+          z.object({}).strict().parse(args)
+          const path=`/tmp/yaoyao-frame-${randomUUID()}.png`
+          const state=await execute([CUA_DRIVER,'call','get_desktop_state','{}','--socket',CUA_SOCKET,'--screenshot-out-file',path])
+          const data=(await execute(['base64','-w0',path])).stdout.trim()
+          return {_multimodal:true,text_summary:state.stdout,content:[{type:'image_url',image_url:{url:`data:image/png;base64,${data}`}}]}
+        }
+        throw new HttpError(403,'本轮未授权该工具','computer_tool_forbidden')
+      })
+    }finally{this.event('tool.completed',{tool_id:id,name})}
+  }
+  private async finish(live:Live,frame:WorkerFrame){
+    if(this.closed||live.controller.signal.aborted)return
+    await this.authorized()
+    this.runtime.pool.renew(live.lease)
+    const success=frame.completed!==false&&!frame.interrupted
+    if(success&&Array.isArray(frame.messages)){live.session.history=frame.messages;live.session.outcome='complete'}else live.session.outcome='failed'
+    this.runtime.save(live.session)
+    await this.release(live)
+    live.running=false
+    if(this.closed||live.controller.signal.aborted)return
+    this.event('message.complete',{text:String(frame.text??''),status:success?'complete':'failed',...(!success?{error:'隔离 Worker 未完成本轮'}:{})})
+  }
+  private release(live:Live){clearInterval(this.timer);live.releasing??=this.runtime.pool.release(live.lease);return live.releasing}
+  private async lost(live:Live){
+    if(!live.running||this.closed||live.controller.signal.aborted)return
+    live.running=false;await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()
+  }
+  async interrupt(){
+    const live=this.live;if(!live)return
+    if(live.stopping)return live.stopping
+    live.stopping=(async()=>{
+      live.controller.abort();clearInterval(this.timer)
+      await live.worker?.close()
+      try{await this.release(live)}catch(error){if((error as any).code!=='computer_lease_stale')throw error}
+      live.running=false
+    })()
+    return live.stopping
+  }
+  close(){this.closed=true;this.controller.abort();this.team=undefined;clearInterval(this.timer);return this.interrupt()}
+}

+ 172 - 0
src/runner/worker/hermes_worker.py

@@ -0,0 +1,172 @@
+"""Hermes model loop with a closed, parent-owned tool dispatcher.
+
+Credentials and this process remain on the Runner. Tools operate through the
+parent's leased computer; no model-selected native Hermes handler is invoked.
+Only private stdin/stdout carry protocol frames. Ordinary logging uses stderr.
+"""
+from __future__ import annotations
+import contextlib
+import json
+import os
+from pathlib import Path
+import re
+import sys
+import threading
+import uuid
+
+WIRE = sys.stdout
+WRITE_LOCK = threading.Lock()
+BOOT = json.loads(sys.stdin.readline())
+NONCE = BOOT["nonce"]
+
+def emit(kind, **values):
+    with WRITE_LOCK:
+        WIRE.write(json.dumps({"nonce": NONCE, "type": kind, **values}, ensure_ascii=False) + "\n")
+        WIRE.flush()
+
+def resolve_model():
+    source = Path(BOOT["hermesSource"]).resolve()
+    root = Path(BOOT["hermesHome"]).resolve()
+    profile = BOOT["profile"]
+    if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", profile):
+        raise ValueError("invalid profile")
+    home = root if profile == "default" else root / "profiles" / profile
+    if not (home / "config.yaml").is_file():
+        raise ValueError("profile config missing")
+    os.environ["HERMES_HOME"] = str(home)
+    sys.path.insert(0, str(source))
+    from dotenv import dotenv_values
+    for key, value in dotenv_values(home / ".env").items():
+        if value is not None:
+            os.environ[key] = value
+    from hermes_constants import set_hermes_home_override
+    set_hermes_home_override(str(home))
+    from hermes_cli.config import load_config_readonly
+    from hermes_cli.runtime_provider import resolve_runtime_provider
+    config = load_config_readonly()
+    model = config.get("model", {})
+    if not isinstance(model, dict) or not model.get("default"):
+        raise ValueError("model config missing")
+    runtime = resolve_runtime_provider(requested=model.get("provider"), explicit_api_key=model.get("api_key"), explicit_base_url=model.get("base_url"), target_model=model["default"])
+    if runtime.get("api_mode") not in ("chat_completions", "anthropic_messages", "codex_responses"):
+        raise ValueError("provider cannot run with closed tool dispatch")
+    cwd = config.get("terminal", {}).get("cwd")
+    if not isinstance(cwd, str) or not cwd.strip():
+        raise ValueError("terminal.cwd required")
+    cwd = os.path.normpath(os.path.expanduser(cwd.strip()))
+    if not os.path.isabs(cwd):
+        raise ValueError("absolute terminal.cwd required")
+    emit("resolved", model={key: runtime[key] for key in ("provider", "api_mode", "base_url", "api_key") if key in runtime} | {"model": model["default"]}, cwd=cwd, configuredCwd=config["terminal"]["cwd"])
+
+
+def run_model():
+    home = Path(BOOT["home"]).resolve()
+    home.mkdir(parents=True, exist_ok=True, mode=0o700)
+    os.chdir(home)
+    os.environ["HOME"] = str(home)
+    os.environ["HERMES_HOME"] = str(home)
+    os.environ["HERMES_CWD"] = str(home)
+    sys.path.insert(0, str(Path(BOOT["hermesSource"]).resolve()))
+    (home / "config.yaml").write_text("plugins:\n  enabled: []\ncompression:\n  enabled: false\n", encoding="utf-8")
+    (home / ".env").touch(mode=0o600)
+    # Prevent the installed project's dotenv fallback from importing host secrets.
+    import hermes_cli.env_loader as env_loader
+    env_loader.load_hermes_dotenv = lambda *args, **kwargs: []
+    import run_agent
+    # The registry remains empty even if Hermes refreshes its tool snapshot.
+    run_agent.get_tool_definitions = lambda *args, **kwargs: []
+    pending = {}
+    stopped = threading.Event()
+    allowed = {item["name"] for item in BOOT["tools"]}
+
+    class ClosedAgent(run_agent.AIAgent):
+        def _execute_tool_calls(self, assistant_message, messages, effective_task_id, api_call_count=0):
+            # Intercept before Hermes' sequential, parallel and inline branches.
+            # In particular message_agent/delegate_task must never reach native code.
+            from agent.tool_dispatch_helpers import make_tool_result_message
+            for tool in assistant_message.tool_calls:
+                name = tool.function.name
+                try:
+                    arguments = json.loads(tool.function.arguments)
+                    if not isinstance(arguments, dict):
+                        raise ValueError("arguments must be an object")
+                    result = self._invoke_tool(name, arguments, effective_task_id, tool.id)
+                except Exception:
+                    result = json.dumps({"error": "Worker tool failed or was cancelled"})
+                if isinstance(result, dict) and result.get("_multimodal"):
+                    result = [{"type": "text", "text": result.get("text_summary", "")}, *result.get("content", [])]
+                messages.append(make_tool_result_message(name, result, tool.id))
+
+        def _invoke_tool(self, function_name, function_args, effective_task_id, tool_call_id=None, **kwargs):
+            if stopped.is_set() or function_name not in allowed:
+                return json.dumps({"error": "This tool is not authorized for this worker"})
+            call_id = str(uuid.uuid4())
+            event = threading.Event()
+            slot = {"event": event}
+            pending[call_id] = slot
+            emit("tool", id=call_id, name=function_name, arguments=function_args)
+            try:
+                if not event.wait(90) or stopped.is_set():
+                    stopped.set()
+                    emit("failed", message="Worker 工具调用已超时或停止")
+                    self.interrupt()
+                    raise RuntimeError("worker tool cancelled or timed out")
+                result = slot.get("result", {"error": "No tool response"})
+                return result if isinstance(result, str) or (isinstance(result, dict) and result.get("_multimodal")) else json.dumps(result, ensure_ascii=False)
+            finally:
+                pending.pop(call_id, None)
+
+    from tools.registry import registry
+    def no_native_dispatch(*args, **kwargs):
+        raise RuntimeError("closed worker requires parent dispatch")
+    for item in BOOT["tools"]:
+        registry.register(name=item["name"], toolset="yaoyao_computer", schema={"name": item["name"], "description": item["description"], "parameters": item["inputSchema"]}, handler=no_native_dispatch)
+
+    model = BOOT["model"]
+    if model.get("api_mode") not in ("chat_completions", "anthropic_messages", "codex_responses"):
+        raise ValueError("unsupported worker provider")
+    agent = ClosedAgent(**model, enabled_toolsets=[], disabled_toolsets=None, quiet_mode=True,
+        save_trajectories=False, skip_context_files=True, skip_memory=True,
+        skip_background_review=True, checkpoints_enabled=False, session_id=BOOT["sessionId"],
+        max_iterations=32, run_budget_seconds=900,
+        stream_delta_callback=lambda text, **kwargs: emit("delta", text=str(text)) if text is not None else None,
+        reasoning_callback=lambda text, **kwargs: emit("reasoning", text=str(text)) if text is not None else None)
+    agent.tools = [{"type": "function", "function": {"name": item["name"], "description": item["description"], "parameters": item["inputSchema"]}} for item in BOOT["tools"]]
+    agent.valid_tool_names = set(allowed)
+
+    def receive():
+        try:
+            for line in sys.stdin:
+                frame = json.loads(line)
+                if frame.get("nonce") != NONCE:
+                    continue
+                if frame.get("type") == "result" and frame.get("id") in pending:
+                    slot = pending[frame["id"]]
+                    slot["result"] = frame.get("result")
+                    slot["event"].set()
+                elif frame.get("type") == "interrupt":
+                    break
+        finally:
+            stopped.set()
+            agent.interrupt()
+            for slot in list(pending.values()):
+                slot["event"].set()
+    threading.Thread(target=receive, daemon=True).start()
+    emit("ready", tools=sorted(allowed))
+    result = agent.run_conversation(BOOT["prompt"], conversation_history=BOOT.get("history", []), task_id=BOOT["taskId"],
+        system_message="You are a controlled Hermes worker. All tools operate on your assigned isolated Linux computer. Use Linux paths and shell syntax. Use only the supplied tools. The working directory is " + BOOT["cwd"] + ". Never claim host files or credentials are available. Follow the user's task and permission scope.")
+    emit("complete", text=str(result.get("final_response") or ""), messages=result.get("messages", []), interrupted=stopped.is_set(), completed=result.get("completed", True))
+
+try:
+    with contextlib.redirect_stdout(sys.stderr):
+        if BOOT.get("mode") == "resolve":
+            resolve_model()
+        else:
+            run_model()
+except BaseException as error:
+    if BOOT.get("diagnostic"):
+        import traceback
+        traceback.print_exc(file=sys.stderr)
+    # Never serialize SDK exception strings, request headers, or credentials.
+    emit("failed", message="Hermes Worker 执行失败,请核对基础 Profile 与模型连接")
+    sys.exit(1)

+ 59 - 0
src/runner/worker/process.ts

@@ -0,0 +1,59 @@
+import {spawn,type ChildProcessWithoutNullStreams} from 'node:child_process'
+import {randomUUID} from 'node:crypto'
+import {homedir} from 'node:os'
+import {HttpError} from '../../server/errors.js'
+
+export interface WorkerTool {id?:string;name:string;description:string;inputSchema:Record<string,unknown>}
+export interface WorkerModel {provider:string;api_mode:string;base_url?:string;api_key?:string;model:string}
+export interface WorkerFrame {type:string;[key:string]:any}
+export class HermesWorkerProcess {
+  readonly nonce=randomUUID()
+  readonly child:ChildProcessWithoutNullStreams
+  readonly exited:Promise<void>
+  private finished=false
+  private output=''
+  private pending=new Set<Promise<unknown>>()
+  private waiters=new Map<string,{resolve(frame:WorkerFrame):void;reject(error:Error):void;timer:ReturnType<typeof setTimeout>}>()
+  onEvent:(frame:WorkerFrame)=>void=()=>{}
+  onTool:(name:string,args:unknown,id:string)=>Promise<unknown>=async()=>{throw new Error('工具未授权')}
+  constructor(python:string,script:string,input:Record<string,unknown>,onDiagnostic?:(text:string)=>void){
+    this.child=spawn(python,['-u',script],{stdio:'pipe',env:{PATH:process.env.PATH??'/usr/bin:/bin',HOME:homedir(),PYTHONNOUSERSITE:'1',PYTHONDONTWRITEBYTECODE:'1'}})
+    if(onDiagnostic)this.child.stderr.on('data',chunk=>onDiagnostic(String(chunk)));else this.child.stderr.resume()
+    this.exited=new Promise(resolve=>{
+      const done=()=>{if(this.finished)return;this.finished=true;this.fail(new Error('Hermes Worker 已退出'));resolve()}
+      this.child.once('error',done);this.child.once('exit',done)
+    })
+    this.child.stdout.on('data',chunk=>{
+      this.output+=String(chunk)
+      if(Buffer.byteLength(this.output)>128*1024*1024){void this.close();return}
+      for(;;){const end=this.output.indexOf('\n');if(end<0)break;const line=this.output.slice(0,end);this.output=this.output.slice(end+1);try{const frame=JSON.parse(line);if(frame.nonce===this.nonce)this.receive(frame)}catch{/* non-protocol output has no authority */}}
+    })
+    this.child.stdin.on('error',()=>{})
+    this.child.stdin.write(JSON.stringify({...input,nonce:this.nonce})+'\n')
+  }
+  private receive(frame:WorkerFrame){
+    const waiting=this.waiters.get(frame.type)
+    if(waiting){clearTimeout(waiting.timer);this.waiters.delete(frame.type);waiting.resolve(frame)}
+    if(frame.type==='failed')this.fail(new HttpError(502,'Hermes Worker 执行失败,请核对基础 Profile 与模型连接','computer_worker_failed'))
+    if(frame.type==='tool'){
+      const work=Promise.resolve().then(()=>this.onTool(String(frame.name),frame.arguments,String(frame.id))).then(result=>this.send({type:'result',id:frame.id,result}),()=>this.send({type:'result',id:frame.id,result:{error:'工具执行失败或授权已失效'}}))
+      this.pending.add(work);void work.finally(()=>this.pending.delete(work)).catch(()=>{})
+    }else this.onEvent(frame)
+  }
+  private fail(error:Error){for(const value of this.waiters.values()){clearTimeout(value.timer);value.reject(error)}this.waiters.clear()}
+  private send(value:unknown){if(!this.finished&&!this.child.stdin.destroyed)this.child.stdin.write(JSON.stringify({...value as object,nonce:this.nonce})+'\n')}
+  wait(type:string,timeout=30000):Promise<WorkerFrame>{
+    if(this.finished)return Promise.reject(new Error('Hermes Worker 已退出'))
+    return new Promise((resolve,reject)=>{const timer=setTimeout(()=>{this.waiters.delete(type);reject(new Error('Hermes Worker 回应超时'))},timeout);this.waiters.set(type,{resolve,reject,timer})})
+  }
+  async close(){
+    if(this.finished)return
+    this.send({type:'interrupt'})
+    let timer:ReturnType<typeof setTimeout>|undefined
+    try{await Promise.race([this.exited,new Promise(resolve=>{timer=setTimeout(resolve,1500)})])}finally{clearTimeout(timer)}
+    if(!this.finished)this.child.kill('SIGTERM')
+    try{await Promise.race([this.exited,new Promise(resolve=>{timer=setTimeout(resolve,1500)})])}finally{clearTimeout(timer)}
+    if(!this.finished)this.child.kill('SIGKILL')
+    await this.exited
+  }
+}

+ 2 - 1
src/server/app.ts

@@ -208,12 +208,13 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const workspaceNodes = new WorkspaceNodes(workspace, config, { url: config.upstream, client: upstream, session: upstreamSession }, pairings.nodeID)
   workspaceNodes.sourceAllowed = (owner, nodeId, profile) => auth.canUseSource(owner, nodeId, profile)
   const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
-  workspaceNodes.runnerTarget=(owner,nodeId)=>runners.target(owner,nodeId)
+  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)
   workspaceRuntime.onTeamCreated = (owner, team) => {
     try { push.setGroupSubscription(owner, team.id, true, team.lastSeq) } catch { /* Optional notifications do not undo a team. */ }
   }
   const workspaceAssets = new WorkspaceAssets(workspace, workspaceNodes, config.home)
+  workspaceRuntime.publishArtifact=(owner,message,agent,name,bytes)=>workspaceAssets.publish(owner,message,agent,name,bytes)
   workspaceRuntime.onMessage = (owner, message) => workspaceAssets.archive(owner, message)
   workspaceRuntime.onNotify = (owner, c, run, message, interaction) => {
     if (c.kind === 'group' && !push.isGroupSubscribed(owner,c.id)) return

+ 54 - 16
src/server/runnerHub.ts

@@ -14,21 +14,23 @@ const hash = (value: string) => createHash('sha256').update(value).digest('hex')
 const input = z.object({ name:z.string().trim().min(1).max(100),sourceNodeId:z.string().min(1).max(100).default('local'),
   allowedProfiles:z.array(z.string().min(1).max(100)).min(1).max(256) }).strict()
 interface Pending { command:RunnerCommand; resolve(value:any):void; reject(error:Error):void; timer:ReturnType<typeof setTimeout>; nextDelivery:number; valid():boolean }
-interface GatewayConnection { runnerId:string; valid():boolean; onEvent(frame:GatewayFrame):void; onDisconnect():void }
+interface GatewayConnection { runnerId:string;cleanupOnly?:boolean;publishArtifact?:(name:string,bytes:Buffer)=>Promise<unknown>; valid():boolean; onEvent(frame:GatewayFrame):void; onDisconnect():void }
 interface Lease { runnerId:string; input:LeaseInput; calls:Map<string,{fingerprint:string;result:Promise<unknown>}> }
 
 /** Outbound-only machine transport. Browser auth and machine auth remain separate. */
 export class RunnerHub {
   readonly epoch=randomUUID()
-  private online = new Map<string,{instance:string;seen:number;epoch:string;wake?:()=>void}>()
+  private online = new Map<string,{instance:string;seen:number;epoch:string;features:string[];wake?:()=>void}>()
   private retired = new Map<string,Set<string>>()
   private pending = new Map<string,Map<string,Pending>>()
   private connections = new Map<string,GatewayConnection>()
+  private artifacts=new Map<string,{runnerId:string;connectionId:string;name:string;size:number;digest:string;chunks:Buffer[];bytes:number;next:number;result?:unknown}>()
   private leases = new Map<string,Lease>()
   private watchdog: ReturnType<typeof setInterval>
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly local:GatewayTarget) {
     this.watchdog=setInterval(()=>{
       for(const [id,state] of this.online) if(Date.now()-state.seen>35_000)this.disconnect(id)
+      for(const [id,upload] of this.artifacts)if(!this.connections.has(upload.connectionId))this.artifacts.delete(id)
     },5000);this.watchdog.unref()
   }
   records():RunnerRecord[] {return this.store.list('_system','runner')}
@@ -46,7 +48,7 @@ export class RunnerHub {
   }
   summary(record:RunnerRecord) {
     const {tokenHash:_secret,sourceOwner:_owner,...publicRecord}=record
-    return {...publicRecord,online:!!this.online.get(record.id)&&Date.now()-this.online.get(record.id)!.seen<35_000}
+    return {...publicRecord,features:this.online.get(record.id)?.features??[],online:!!this.online.get(record.id)&&Date.now()-this.online.get(record.id)!.seen<35_000}
   }
   remove(id:string) {
     const record=this.store.require<RunnerRecord>('_system','runner',id)
@@ -72,18 +74,20 @@ export class RunnerHub {
       queue.set(command.id,{command,resolve,reject,timer,nextDelivery:0,valid});this.online.get(id)?.wake?.()
     })
   }
-  target(owner:string,sourceNodeId:string):GatewayTarget|undefined {
+  target(owner:string,sourceNodeId:string,computer?:import('../runner/worker/gateway.js').ComputerTarget):GatewayTarget|undefined {
     const record=this.records().find(r=>r.enabled&&r.sourceNodeId===sourceNodeId&&(r.sourceOwner==='_system'||r.sourceOwner===owner))
     if(!record)return undefined
     const requireProfile=(profile:string)=>{
       if(!this.auth.canUseSource(owner,sourceNodeId,profile)||!record.allowedProfiles.includes(profile))throw new HttpError(403,'执行节点未授权这个基础 Profile','runner_profile_forbidden')
     }
+    const requireComputer=()=>{if(computer&&!this.online.get(record.id)?.features.includes('computer-worker-v1'))throw new HttpError(409,'执行节点未提供隔离 Worker 能力,请检查配置或更新 Runner','computer_unavailable')}
     const requestHTTP=async(path:string,options:UpstreamRequestOptions={}):Promise<UpstreamResponse>=>{
+      requireComputer()
       const profile=options.search?.get('profile')??'default'
       if(path!=='/api/profiles'&&path!=='/api/status')requireProfile(profile)
       const version=this.auth.pushAuthorizationVersion(owner)
       const valid=()=>this.auth.isUserActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version&&this.store.get<RunnerRecord>('_system','runner',record.id)?.enabled===true
-      const result=await this.request(record.id,'http',{path,method:options.method??'GET',search:options.search?.toString(),body:options.body},valid)
+      const result=await this.request(record.id,'http',{path,method:options.method??'GET',search:options.search?.toString(),body:options.body,computer},valid)
       if(!valid())throw new HttpError(403,'读取权限已失效','runner_authorization_revoked')
       if(path==='/api/profiles') {
         const parsed=JSON.parse(Buffer.from(result.body,'base64').toString())
@@ -93,17 +97,21 @@ export class RunnerHub {
       return {status:result.status,headers:new Headers(result.headers),body:Buffer.from(result.body,'base64')}
     }
     return {url:this.local.url,client:this.local.client,session:{request:requestHTTP,webSocketCredential:async()=>{throw new Error('Runner uses its own authenticated gateway')}},
-      runner:{id:record.id,
-        open:async(onEvent,onDisconnect)=>{
+      runner:{id:record.id,computer:!!computer,
+        open:async(onEvent,onDisconnect,scope)=>{
+          requireComputer()
+          if(computer&&!scope)throw new HttpError(403,'隔离执行缺少任务授权','computer_scope_required')
           const version=this.auth.pushAuthorizationVersion(owner)
-          const valid=()=>this.auth.isUserActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version&&this.store.get<RunnerRecord>('_system','runner',record.id)?.enabled===true
-          const id=randomUUID();this.connections.set(id,{runnerId:record.id,valid,onEvent,onDisconnect})
-          try {await this.request(record.id,'gateway.open',{connectionId:id})} catch(error){this.connections.delete(id);void this.request(record.id,'gateway.close',{connectionId:id}).catch(()=>{});throw error}
+          const valid=()=>{try{scope?.authorize();return (scope?.cleanupOnly||(this.auth.isUserActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version))&&this.store.get<RunnerRecord>('_system','runner',record.id)?.enabled===true}catch{return false}}
+          const id=randomUUID();this.connections.set(id,{runnerId:record.id,valid,onEvent,onDisconnect,publishArtifact:scope?.publishArtifact,cleanupOnly:scope?.cleanupOnly})
+          try {await this.request(record.id,'gateway.open',{connectionId:id,computer,workId:scope?.workId,cleanupOnly:scope?.cleanupOnly})} catch(error){this.connections.delete(id);void this.request(record.id,'gateway.close',{connectionId:id}).catch(()=>{});throw error}
           let closed=false
           return {rpc:async(method,params)=>{
             if(closed)throw new Error('Runner gateway closed')
-            if(!valid())throw new HttpError(403,'本轮执行授权已失效','runner_command_not_admitted')
-            if(method==='session.create'||method==='session.resume')requireProfile(String(params.profile??'default'))
+            const cleanup=['session.interrupt','session.close'].includes(method)
+            if(scope?.cleanupOnly&&(!['session.resume','session.interrupt','session.close'].includes(method)||(method==='session.resume'&&params.session_id!==scope.sessionId)))throw new HttpError(403,'清理通道只能操作原绑定会话','runner_cleanup_forbidden')
+            if(!cleanup&&!valid())throw new HttpError(403,'本轮执行授权已失效','runner_command_not_admitted')
+            if(!scope?.cleanupOnly&&(method==='session.create'||method==='session.resume'))requireProfile(String(params.profile??'default'))
             return this.request(record.id,'gateway.rpc',{connectionId:id,method,params})
           },close:()=>{
             closed=true;this.connections.delete(id)
@@ -112,7 +120,7 @@ export class RunnerHub {
           }}
         },
         lease:async(options)=>{
-          requireProfile(options.profile);options.assertActive()
+          requireComputer();requireProfile(options.profile);options.assertActive()
           const id=randomUUID();this.leases.set(id,{runnerId:record.id,input:options,calls:new Map()})
           let disposed=false
           const dispose=async()=>{if(disposed)return;disposed=true;this.leases.delete(id);options.signal.removeEventListener('abort',abort);await this.request(record.id,'lease.close',{leaseId:id}).catch(()=>{})}
@@ -135,7 +143,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)$/.exec(ctx.path)
+      const match=/^\/api\/runner\/v1\/([0-9a-f-]{36})\/(poll|admit|result|event|tool|check|artifact)$/.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))))
@@ -152,7 +160,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(),epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();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(match[2]==='poll')state.features=ctx.get('x-runner-features').split(',').filter(value=>['computer-worker-v1','artifact-chunks-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')
@@ -173,10 +181,40 @@ 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]==='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 connection=this.connections.get(value.connectionId)
+        if(connection?.runnerId!==record.id||!connection.valid()||!connection.publishArtifact)throw new HttpError(403,'产物不属于当前有效任务','artifact_forbidden')
+        let upload=this.artifacts.get(value.id)
+        if(value.phase==='begin'){
+          if(!value.name||value.size===undefined||!value.digest)throw new HttpError(400,'产物信息不完整','artifact_invalid')
+          if(upload&&(upload.connectionId!==value.connectionId||upload.name!==value.name||upload.size!==value.size||upload.digest!==value.digest))throw new HttpError(409,'产物编号已用于其他内容','idempotency_conflict')
+          if(!upload){
+            const current=[...this.artifacts.values()]
+            if(current.filter(item=>item.connectionId===value.connectionId).length>=8||current.filter(item=>item.runnerId===record.id&&item.result===undefined).reduce((sum,item)=>sum+item.size,0)+value.size>64*1024*1024)throw new HttpError(429,'待传产物过多','artifact_limit')
+            upload={runnerId:record.id,connectionId:value.connectionId,name:value.name,size:value.size,digest:value.digest,chunks:[],bytes:0,next:0};this.artifacts.set(value.id,upload)
+          }
+        }else{
+          if(!upload||upload.connectionId!==value.connectionId||upload.runnerId!==record.id)throw new HttpError(404,'产物传输不存在','artifact_missing')
+          if(value.phase==='append'){
+            if(value.index===undefined||value.data===undefined||upload.result!==undefined)throw new HttpError(400,'产物分块无效','artifact_invalid')
+            const bytes=Buffer.from(value.data,'base64')
+            if(value.index<upload.next){if(!upload.chunks[value.index]?.equals(bytes))throw new HttpError(409,'重复分块内容不一致','idempotency_conflict')}
+            else{if(value.index!==upload.next||upload.bytes+bytes.length>upload.size)throw new HttpError(409,'分块顺序或大小无效','artifact_invalid');upload.chunks.push(bytes);upload.bytes+=bytes.length;upload.next++}
+          }else if(upload.result===undefined){
+            if(upload.bytes!==upload.size)throw new HttpError(409,'产物尚未传输完整','artifact_incomplete')
+            const bytes=Buffer.concat(upload.chunks)
+            if(createHash('sha256').update(bytes).digest('hex')!==upload.digest)throw new HttpError(409,'产物校验失败','artifact_digest_mismatch')
+            upload.result=await connection.publishArtifact(upload.name,bytes);upload.chunks=[]
+          }
+        }
+        ctx.body={ok:true,next:upload!.next,result:upload!.result};return
+      }
+      if(match[2]==='check'){const connection=this.connections.get(body.connectionId);ctx.body={allowed:connection?.runnerId===record.id&&connection.valid()};return}
       if(match[2]==='admit') {
         const pending=this.pending.get(record.id)?.get(body.id),command=pending?.command
         let allowed=!!command&&command.expiresAt>Date.now()&&pending!.valid()
-        if(command?.kind==='gateway.rpc'||command?.kind==='gateway.open')allowed&&=this.connections.get(String(command.payload.connectionId))?.valid()===true
+        if(command?.kind==='gateway.rpc'||command?.kind==='gateway.open'){const connection=this.connections.get(String(command.payload.connectionId));allowed&&=connection?.runnerId===record.id&&(command.kind==='gateway.rpc'&&['session.interrupt','session.close'].includes(String(command.payload.method))||connection.valid())}
         if(command?.kind==='lease.create'||command?.kind==='lease.bind') {
           const lease=this.leases.get(String(command.payload.leaseId))
           try {if(lease?.runnerId!==record.id)allowed=false;else lease.input.assertActive()}catch{allowed=false}

+ 6 - 3
src/server/taskCoordinator.ts

@@ -3,7 +3,7 @@ import { z } from 'zod'
 import { HttpError } from './errors.js'
 import { parse, type WorkspaceStore } from './workspaceStore.js'
 import type { WorkspaceRuntime } from './workspaceRuntime.js'
-import type { WorkspaceAgent as Agent, WorkspaceConversation as Conversation, WorkspaceRun as Run, WorkspaceTask as Task } from '../shared/workspace.js'
+import type { WorkspaceMessage as Message, WorkspaceAgent as Agent, WorkspaceConversation as Conversation, WorkspaceRun as Run, WorkspaceTask as Task } from '../shared/workspace.js'
 import type { AgentGoal, AgentAssignment, TaskDelivery, TaskOrigin } from '../shared/agentTasks.js'
 
 const terminalRun = (status: string) => ['complete', 'failed', 'interrupted'].includes(status)
@@ -319,8 +319,11 @@ export class WorkspaceTaskCoordinator {
         catch { assignment.status = 'blocked';assignment.result = '负责成员的基础 Profile 已撤权';this.saveAssignment(owner, assignment);continue }
         this.store.atomic(() => {
           const content = `由管理员分派的子任务:${assignment.title}\n${assignment.brief}\n验收要求:${assignment.acceptanceCriteria.join(';') || '提供实际结果及依据'}\n${assignment.review ? `上次复核意见:${assignment.review}\n` : ''}${dependencies.map(a => `依赖结果 ${a.title}:${a.result?.slice(0,1500)}`).join('\n')}\n完成后提交结果,由管理员复核,不要自行转交。`
+          const originRun=this.store.get<Run>(owner,'run',goal.origin.runId)
+          const inputFiles=originRun?this.store.get<Message>(owner,'message',originRun.messageId)?.attachments.map(file=>file.id)??[]:[]
+          const fileIds=[...new Set([...inputFiles,...dependencies.flatMap(item=>item.artifactIds??[])])].slice(0,8)
           const run = this.runtime.dispatch(owner, goal.conversationId,
-            { requestId: requestId(`assignment:${assignment.id}:${assignment.attempt}`), taskId: goal.id, content },
+            { requestId: requestId(`assignment:${assignment.id}:${assignment.attempt}`), taskId: goal.id, content, fileIds },
             { agentId: goal.coordinatorId, assignmentId: assignment.id, targetAgentId: assignment.agentId, kind: 'assignment' })
           assignment.runId = run.id;assignment.attempt++;assignment.status = 'running';this.saveAssignment(owner, assignment)
         })
@@ -378,7 +381,7 @@ export class WorkspaceTaskCoordinator {
     this.store.atomic(() => {
       this.store.put(owner, 'task-delivery', id, delivery)
       const run = this.runtime.dispatch(owner, source!.id,
-        { requestId: id, taskId: goal.origin.conversationTaskId, content: text },
+        { requestId: id, taskId: goal.origin.conversationTaskId, content: text, fileIds:[...new Set(assignments.flatMap(assignment=>assignment.artifactIds??[]))].slice(0,8) },
         { agentId: goal.coordinatorId, kind: kind === 'review' ? 'task_review' : 'task_result', instruction,
           taskReference:{conversationId:goal.conversationId,taskId:goal.id} })
       delivery!.runId = run.id;delivery!.status = 'delivered';this.store.put(owner, 'task-delivery', id, delivery)

+ 20 - 0
src/server/workspaceAssets.ts

@@ -1,6 +1,7 @@
 import { randomUUID, createHash } from 'node:crypto'
 import { mkdirSync, writeFileSync } from 'node:fs'
 import { join, basename } from 'node:path'
+import {HttpError} from './errors.js'
 import { lookup } from 'mime-types'
 import { WorkspaceStore } from './workspaceStore.js'
 import { WorkspaceNodes } from './workspaceGateway.js'
@@ -8,6 +9,7 @@ import type { WorkspaceAgent, WorkspaceFile, WorkspaceMessage } from '../shared/
 export interface StoredWorkspaceFile extends WorkspaceFile {
   path: string
   digest?: string
+  sourceNodeId?:string
 }
 export function publicFile(f: StoredWorkspaceFile): WorkspaceFile {
   const { path: _path, digest: _digest, ...result } = f
@@ -80,6 +82,22 @@ export class WorkspaceAssets {
     readonly nodes: WorkspaceNodes,
     readonly home: string,
   ) {}
+  publish(owner:string,message:WorkspaceMessage,agent:WorkspaceAgent,name:string,bytes:Buffer):WorkspaceFile {
+    const digest=createHash('sha256').update(bytes).digest('hex')
+    name=basename(name).replace(/[\\/\u0000-\u001f\u007f]/g,'_').slice(0,240)||'artifact'
+    const files=this.store.list<StoredWorkspaceFile>(owner,'file')
+    const existing=files.find(file=>file.messageId===message.id&&file.name===name&&file.digest===digest)
+    if(existing)return publicFile(existing)
+    if(bytes.length>25*1024*1024||files.filter(file=>file.messageId===message.id).length>=8)throw new HttpError(413,'每轮最多 8 个产物,每个不超过 25 MiB','artifact_limit')
+    const used=Number(this.store.db.prepare("SELECT coalesce(sum(json_extract(data,'$.size')),0) AS n FROM workspace_entities WHERE kind='file'").get()?.n??0)
+    if(used+bytes.length>2*1024*1024*1024)throw new HttpError(413,'产物存储空间已达上限','artifact_quota')
+    const dir=join(this.home,'workspace-files',owner);mkdirSync(dir,{recursive:true,mode:0o700})
+    const path=join(dir,digest)
+    try{writeFileSync(path,bytes,{mode:0o600,flag:'wx'})}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error}
+    const file:StoredWorkspaceFile={id:randomUUID(),name,mimeType:lookup(name)||'application/octet-stream',path,digest,size:bytes.length,sender:'agent',profile:agent.profile,sourceNodeId:agent.nodeId,conversationId:message.conversationId,messageId:message.id,createdAt:Date.now()}
+    this.store.atomic(()=>{this.store.put(owner,'file',file.id,file);this.store.event(owner,'files.changed',publicFile(file),message.conversationId)})
+    return publicFile(file)
+  }
   async archive(owner: string, message: WorkspaceMessage): Promise<void> {
     if (!message.agentId) return
     const agent = this.store.require<WorkspaceAgent>(owner, 'agent', message.agentId)
@@ -114,6 +132,8 @@ export class WorkspaceAssets {
     remoteAgentId?: string,
   ): Promise<void> {
     if (this.stopped) return
+    const message=this.store.get<WorkspaceMessage>(owner,'message',messageId)
+    if(message?.execution==='computer'||(!message?.execution&&message?.agentId&&this.store.get<WorkspaceAgent>(owner,'agent',message.agentId)?.execution==='computer'))return
     const paths = new Set<string>()
     for (const m of text.matchAll(
       /(?:MEDIA:\s*|\]\(<?|"(?:path|file_path|output_path)"\s*:\s*")((?:\/|~\/)[^\n"<>)]{1,4096})/g,

+ 14 - 6
src/server/workspaceGateway.ts

@@ -1,5 +1,5 @@
 import WebSocket from 'ws'
-import { randomUUID, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'
+import { randomUUID, randomBytes, createHash, createCipheriv, createDecipheriv } from 'node:crypto'
 import { readFileSync, writeFileSync } from 'node:fs'
 import { join } from 'node:path'
 import { UpstreamClient, CookieJar } from './upstream.js'
@@ -20,6 +20,7 @@ export interface WorkspaceNode {
   fingerprint?: string
   deviceId?: string
 }
+export interface GatewayExecutionScope {workId:string;cleanupOnly?:boolean;sessionId?:string;authorize():void;publishArtifact?(name:string,bytes:Buffer):Promise<unknown>}
 export interface GatewayTarget {
   url: URL
   client: UpstreamClient
@@ -27,7 +28,8 @@ export interface GatewayTarget {
   pairedToken?: string
   runner?: {
     id: string
-    open(onEvent:(frame:GatewayFrame)=>void,onDisconnect:()=>void):Promise<{rpc(method:string,params:Record<string,unknown>):Promise<any>;close():void}>
+    computer?:boolean
+    open(onEvent:(frame:GatewayFrame)=>void,onDisconnect:()=>void,scope?:GatewayExecutionScope):Promise<{rpc(method:string,params:Record<string,unknown>):Promise<any>;close():void}>
     lease(input:import('./workspaceToolLease.js').LeaseInput):Promise<import('./workspaceToolLease.js').WorkspaceToolLease>
   }
 }
@@ -37,7 +39,7 @@ export interface GatewayFrame {
   payload?: Record<string, any>
 }
 export class WorkspaceNodes {
-  runnerTarget?: (owner:string,nodeId:string)=>GatewayTarget|undefined
+  runnerTarget?: (owner:string,nodeId:string,computer?:{environmentId:string;ownerKey:string;agentId:string})=>GatewayTarget|undefined
   sourceAllowed: (owner: string, nodeId: string, profile: string) => boolean = () => true
   requireSource(owner: string, agent: { nodeId: string; profile: string }): void {
     if (!this.sourceAllowed(owner, agent.nodeId, agent.profile)) throw new HttpError(403, '基础 Agent 未分配给当前账号', 'agent_source_forbidden')
@@ -102,7 +104,13 @@ export class WorkspaceNodes {
     }
     return target
   }
-  targetForAgent(owner: string, agent: { nodeId: string; remoteAgentId?: string }): GatewayTarget {
+  targetForAgent(owner: string, agent: { id?:string;nodeId: string; remoteAgentId?: string;execution?:string }): GatewayTarget {
+    if(agent.execution==='computer'){
+      if(!agent.id||agent.remoteAgentId)throw new HttpError(409,'隔离电脑需要当前服务器管理的 Agent','computer_agent_required')
+      const target=this.runnerTarget?.(owner,agent.nodeId,{environmentId:agent.id,agentId:agent.id,ownerKey:createHash('sha256').update(owner).digest('hex')})
+      if(!target)throw new HttpError(409,'隔离电脑需要连接执行节点','computer_runner_required')
+      return target
+    }
     const base = this.target(owner, agent.nodeId)
     if (!agent.remoteAgentId) return base
     if (!base.pairedToken) throw new HttpError(409, '引用远端 Agent 需要扫码子节点', 'paired_node_required')
@@ -280,9 +288,9 @@ export class WorkspaceGateway {
   >()
   onEvent: (frame: GatewayFrame) => void = () => {}
   onDisconnect: () => void = () => {}
-  constructor(readonly target: GatewayTarget) {}
+  constructor(readonly target: GatewayTarget,readonly scope?:GatewayExecutionScope) {}
   async connect(): Promise<void> {
-    if(this.target.runner){this.runner=await this.target.runner.open(frame=>this.onEvent(frame),()=>this.onDisconnect());return}
+    if(this.target.runner){this.runner=await this.target.runner.open(frame=>this.onEvent(frame),()=>this.onDisconnect(),this.scope);return}
     if (this.target.pairedToken) {
       this.paired = new WorkspacePairedChannel(this.target.url, this.target.pairedToken, this.target.client.fetchImpl,
         frame => this.onEvent(frame), () => this.onDisconnect())

+ 9 - 5
src/server/workspaceRoutes.ts

@@ -18,6 +18,7 @@ import { HttpError } from './errors.js'
 import type { PushCoordinator } from './pushCoordinator.js'
 import type { LocalAuthStore } from './localAuth.js'
 import type {
+  WorkspaceMessage,
   WorkspaceAgent,
   WorkspaceConversation,
   WorkspaceRun,
@@ -49,9 +50,11 @@ export function workspaceRouter(
       const row = store.db
         .prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='file' AND rowid=?")
         .get(user, Number(id))
-      if (row) return JSON.parse(String(row.data))
+      if (row) {const file:StoredWorkspaceFile=JSON.parse(String(row.data));if(file.sourceNodeId&&file.profile)nodes.requireSource(user,{nodeId:file.sourceNodeId,profile:file.profile});return file}
     }
-    return store.require(user, 'file', id)
+    const file=store.require<StoredWorkspaceFile>(user,'file',id)
+    if(file.sourceNodeId&&file.profile)nodes.requireSource(user,{nodeId:file.sourceNodeId,profile:file.profile})
+    return file
   }
   router.get('/api/app/capabilities', (ctx) => {
     owner(ctx)
@@ -98,7 +101,7 @@ export function workspaceRouter(
     if (!sources.sources.some((s) => s.nodeId === input.nodeId && s.profile === input.profile))
       throw new HttpError(409, '基础 Agent 当前不可用', 'source_unavailable')
     nodes.requireSource(user, input)
-    if (input.canManageTeam) await runtime.teamTools.requireAvailable(user, input)
+    if (input.canManageTeam || input.execution==='computer') await runtime.teamTools.requireAvailable(user, input)
     if (auth.pushAuthorizationVersion(user) !== authorization) throw new HttpError(401,'账号授权已变化,请重新登录','session_revoked')
     ctx.body = { agent: store.agentSummary(store.createAgent(user, input)) }
     ctx.status = 201
@@ -107,7 +110,7 @@ export function workspaceRouter(
     const user = owner(ctx), agent = store.require<WorkspaceAgent>(user, 'agent', ctx.params.id), input = body(ctx)
     const authorization = auth.pushAuthorizationVersion(user)
     nodes.requireSource(user, agent)
-    if (input.canManageTeam === true && agent.canManageTeam !== true) await runtime.teamTools.requireAvailable(user, agent)
+    if ((input.canManageTeam === true && agent.canManageTeam !== true) || input.execution==='computer') await runtime.teamTools.requireAvailable(user, {...agent,...(input.execution?{execution:input.execution}:{})})
     if (auth.pushAuthorizationVersion(user) !== authorization) throw new HttpError(401,'账号授权已变化,请重新登录','session_revoked')
     nodes.requireSource(user, agent)
     ctx.body = { agent: store.agentSummary(store.updateAgent(user, ctx.params.id, input)) }
@@ -374,7 +377,7 @@ export function workspaceRouter(
         ids.map((id) => [
           id,
           files
-            .filter((f) => f.messageId === id)
+            .filter((f) => f.messageId === id || store.get<WorkspaceMessage>(owner(ctx),'message',id)?.attachments.some(attachment=>attachment.id===f.id))
             .map((f, ordinal) => ({
               itemId: libraryId(owner(ctx), f.id),
               messageId: id,
@@ -406,6 +409,7 @@ export function workspaceRouter(
       )
       ctx.set('Accept-Ranges', 'bytes')
       ctx.set('Cache-Control', 'private, no-store')
+      if(size===0){ctx.body=Buffer.alloc(0);return}
       const range = ctx.get('range'),
         match = /^bytes=(\d*)-(\d*)$/.exec(range)
       let start = 0,

+ 33 - 10
src/server/workspaceRuntime.ts

@@ -5,6 +5,7 @@ import { WorkspaceStore, parse } from './workspaceStore.js'
 import { WorkspaceNodes, WorkspaceGateway, type GatewayFrame } from './workspaceGateway.js'
 import { HttpError } from './errors.js'
 import { applyWorkingDirectory, configuredWorkingDirectory } from './sessionWorkingDirectory.js'
+import {type StoredWorkspaceFile} from './workspaceAssets.js'
 import { UploadStore } from './uploads.js'
 import type {
   WorkspaceAgent as Agent,
@@ -24,6 +25,7 @@ type Run = WorkspaceRun
 
 export interface WorkspaceBinding {
   runnerId?: string
+  execution?:string
   remoteAgentId?: string
   id: string
   nodeId: string
@@ -60,6 +62,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   private live = new Map<string, LiveTurn>()
   readonly teamTools: WorkspaceTeamTools
   readonly tasks: WorkspaceTaskCoordinator
+  publishArtifact:(owner:string,message:Message,agent:Agent,name:string,bytes:Buffer)=>import('../shared/workspace.js').WorkspaceFile=()=>{throw new Error('产物服务未初始化')}
   onTeamCreated: (owner: string, team: Conversation) => void = () => {}
   constructor(store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly uploads: UploadStore, userActive: (owner: string) => boolean = () => true,
     readonly authorizationVersion: (owner: string) => number = () => 0) {
@@ -77,6 +80,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     this.nodes.requireSource(owner, this.store.require<Agent>(owner, 'agent', source.agentId))
     return this.submit(owner, conversationId, input, source)
   }
+  private files(owner:string,ids:string[]){return ids.map(id=>{const file=this.store.get<StoredWorkspaceFile>(owner,'file',id);if(file){if(file.sourceNodeId&&file.profile)this.nodes.requireSource(owner,{nodeId:file.sourceNodeId,profile:file.profile});return file}return {...this.uploads.records([id],owner)[0]!,sender:'user' as const}})}
   private submit(owner: string, conversationId: string, input: unknown, source?: {
     agentId: string; assignmentId?: string; targetAgentId?: string; kind: NonNullable<Run['triggerKind']>; instruction?: string; taskReference?: {conversationId:string;taskId:string}
   }): Run {
@@ -96,7 +100,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       }
       if (body.mentionIds.some((a) => !c.memberIds.includes(a)))
         throw new HttpError(400, '只能 @ 群内成员', 'invalid_mentions')
-      const records = this.uploads.records(body.fileIds, owner)
+      const records = this.files(owner,body.fileIds)
       const now = Date.now(),
         runId = randomUUID(),
         agents = c.memberIds.map((id) => this.store.require<Agent>(owner, 'agent', id))
@@ -122,7 +126,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           name: f.name,
           mimeType: f.mimeType,
           size: f.size,
-          sender: 'user',
+          sender: f.sender,
           createdAt: now,
         })),
         tools: [],
@@ -131,7 +135,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       this.store.saveMessage(owner, message)
       for (const file of records) {
         const existing = this.store.get<Record<string, unknown>>(owner, 'file', file.id)
-        if (existing)
+        if (existing && !existing.messageId)
           this.store.put(owner, 'file', file.id, {
             ...existing,
             conversationId,
@@ -170,10 +174,20 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     this.nodes.requireSource(owner, agent)
     if (agent.remoteAgentId) agent = await this.nodes.refreshRemoteAgent(owner,agent)
     const key = this.bindingKey(c.id, run.conversationTaskId, agent.id),
-      target = agent.remoteAgentId ? this.nodes.targetForAgent(owner,agent) : this.nodes.target(owner, agent.nodeId)
-    const gateway = new WorkspaceGateway(target)
+      target = agent.remoteAgentId || agent.execution==='computer' ? this.nodes.targetForAgent(owner,agent) : this.nodes.target(owner, agent.nodeId)
+    const gateway = new WorkspaceGateway(target,{workId:run.id,publishArtifact:async(name,bytes)=>{
+      this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,agent)
+      const file=this.publishArtifact(owner,resultMessage,agent,name,bytes)
+      if(!resultMessage.attachments.some(item=>item.id===file.id))resultMessage.attachments.push(file)
+      this.store.saveMessage(owner,resultMessage)
+      return {...file,url:`/api/app/files/${file.id}/download`}
+    },authorize:()=>{
+      this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,agent)
+      const current=this.getWork(owner,run.id),latest=this.store.require<Agent>(owner,'agent',agent.id),conversation=this.store.require<Conversation>(owner,'conversation',c.id)
+      if(this.closing||conversation.archived||!conversation.memberIds.includes(agent.id)||this.store.require<Run>(owner,'run',run.runId).stopRequested||current.cancelRequested||['interrupted','complete','failed'].includes(current.status)||latest.archived||(latest.execution??'profile')!==(agent.execution??'profile')||latest.teamAuthorizationVersion!==agent.teamAuthorizationVersion)throw new HttpError(403,'本轮 Agent 或任务授权已结束','run_authorization_revoked')
+    }})
     let binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
-    const movedRunner=!!binding&&binding.runnerId!==target.runner?.id
+    const movedRunner=!!binding&&(binding.runnerId!==target.runner?.id||(binding.execution??'profile')!==(agent.execution??'profile'))
     if(movedRunner) {
       if(recovering)throw new HttpError(409,'执行节点已变化,不能在另一节点重放原执行','runner_target_changed')
       binding=undefined
@@ -189,6 +203,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         conversationTaskId: run.conversationTaskId,
         seq: 0,
         role: 'assistant',
+        execution:agent.execution??'profile',
         agentId: agent.id,
         agentName: agent.name,
         content: '',
@@ -207,6 +222,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         this.saveWork(owner, run)
       })
     }
+    if(!recovering&&message.status==='queued'){message.status='streaming';message.error=undefined;message.visible=run.replyMode!=='automatic'||run.requiredReply}
     const resultMessage = message
     let submitted = recovering,
       settled = false,
@@ -234,7 +250,9 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       this.live.delete(key)
       try {
         const current = this.getWork(owner, run.id)
-        if (error) {
+        if(error&&!submitted&&current.status!=='interrupted'&&error instanceof HttpError&&['computer_busy','computer_quota','runner_offline'].includes(error.code??'')){
+          current.status='queued';current.resourceWait=true;current.error='等待执行节点或电脑资源';resultMessage.status='queued';resultMessage.visible=false;resultMessage.error=undefined
+        }else if (error) {
           current.status = current.status === 'interrupted' ? 'interrupted' : submitted ? 'uncertain' : 'failed'
           current.error = error.message.slice(0, 1000)
           resultMessage.status = current.status
@@ -422,6 +440,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             profile: agent.profile,
             session_id: binding.storedId,
             omit_messages: true,
+            ...(recovering&&target.runner?.computer?{recoverOnly:true}:{}),
             close_on_disconnect: false,
           })
         : await gateway.rpc('session.create', {
@@ -445,6 +464,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         throw new Error('Hermes 会话身份不匹配')
       binding = {
         runnerId: target.runner?.id,
+        execution:agent.execution,
         id: key,
         nodeId: agent.nodeId,
         remoteAgentId: agent.remoteAgentId,
@@ -529,7 +549,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const attachmentRefs: string[] = []
         for (const file of trigger.attachments) {
           this.requireAuthorization(owner, run.runId)
-          const record = this.uploads.records([file.id], owner)[0]!
+          const record = this.files(owner,[file.id])[0]!
           const bytes = readFileSync(record.path).toString('base64')
           const attached = record.mimeType.startsWith('image/')
             ? await gateway.rpc('image.attach_bytes', {
@@ -685,15 +705,18 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       if (['complete', 'failed', 'interrupted'].includes(current.status)) return
       current.status = 'interrupted'; current.error = '已停止'
       this.saveWork(owner, current)
+      if(current.currentMessageId){const message=this.store.require<Message>(owner,'message',current.currentMessageId);message.status='interrupted';message.error='已停止';this.store.saveMessage(owner,message)}
       live.done(new Error('已停止'))
     } else {
       const binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
       if (work.submitted) {
         if (!binding || binding.taskId !== work.id) throw new Error('无法确认待停止成员的会话身份')
-        const gateway = new WorkspaceGateway(binding.remoteAgentId ? this.nodes.targetForAgent(owner,binding) : this.nodes.target(owner, binding.nodeId))
+        const target=binding.remoteAgentId||binding.execution==='computer'?this.nodes.targetForAgent(owner,{...binding,id:work.agentId}):this.nodes.target(owner,binding.nodeId)
+        if(binding.runnerId!==target.runner?.id)throw new Error('执行节点已变化,无法确认原任务已停止')
+        const gateway = new WorkspaceGateway(target,{workId:work.id,cleanupOnly:true,sessionId:binding.storedId,authorize:()=>{const current=this.store.get<WorkspaceBinding>(owner,'binding',key);if(current?.taskId!==work.id||current.storedId!==binding.storedId)throw new Error('原会话绑定已改变')}})
         try {
           await gateway.connect()
-          const opened = await gateway.rpc('session.resume', { profile: binding.profile, session_id: binding.storedId, omit_messages: true, close_on_disconnect: false })
+          const opened = await gateway.rpc('session.resume', { profile: binding.profile, session_id: binding.storedId, omit_messages: true, close_on_disconnect: false,...(binding.execution==='computer'?{recoverOnly:true}:{}) })
           if (opened.running) await gateway.rpc('session.interrupt', { session_id: opened.session_id })
         } finally { gateway.close() }
       }

+ 7 - 4
src/server/workspaceScheduler.ts

@@ -8,6 +8,7 @@ export const NO_REPLY = '[[YAOYAO_NO_REPLY_V1]]'
 export const HOST_FALLBACK = '我还不能确定你希望我处理什么,请补充具体目标、范围,或明确需要我协调的 Agent。'
 const terminal = (status: string) => ['complete', 'failed', 'interrupted'].includes(status)
 export interface Work {
+  resourceWait?:boolean
   id: string
   runId: string
   conversationId: string
@@ -242,7 +243,7 @@ export abstract class WorkspaceScheduler {
         if (occupied.some(t => t.conversationId === c.id && t.conversationTaskId === work.conversationTaskId && t.agentId === work.agentId)) continue
         const inTask = occupied.filter(t => t.conversationId === c.id && t.conversationTaskId === work.conversationTaskId)
         if (work.status === 'queued' && (occupied.length >= 4 || inTask.length >= 3)) continue
-        if (work.status === 'uncertain' && (this.recoverAfter.get(work.id) ?? 0) > Date.now()) { this.retrySoon(); continue }
+        if ((this.recoverAfter.get(work.id) ?? 0) > Date.now()) { this.retrySoon(); continue }
         if (c.mode === 'host' || c.kind === 'direct') {
           if (inTask.some(t => c.kind === 'direct'
             || (!root.assignmentId && (t.runId !== work.runId || t.batchId !== work.batchId))
@@ -251,7 +252,7 @@ export abstract class WorkspaceScheduler {
         this.executing.add(work.id)
         const recovering = work.status === 'uncertain'
         if (!recovering) {
-          work.status = 'running'
+          work.status = 'running';work.resourceWait=false
           if (c.kind === 'group' && c.mode === 'host' && work.agentId === c.administratorId) { work.requiredReply = true; work.replyMode = 'automatic' }
           work.turnConfiguration = { mode: c.mode, administratorId: c.administratorId, members: c.memberIds.map(id => { const a = this.store.require<Agent>(owner, 'agent', id); return { id, name: a.name } }) }
           this.saveWork(owner, work)
@@ -272,7 +273,9 @@ export abstract class WorkspaceScheduler {
     } catch (error) {
       if (this.closing) return
       const current = this.getWork(owner, work.id)
-      if (!terminal(current.status) && current.status !== 'uncertain') {
+      if(!current.submitted&&error instanceof HttpError&&['computer_busy','computer_quota','runner_offline'].includes(error.code??'')&&current.status!=='interrupted'){
+        current.status='queued';current.resourceWait=true;current.error='等待执行节点或电脑资源';this.saveWork(owner,current)
+      }else if (!terminal(current.status) && current.status !== 'uncertain') {
         current.status = current.submitted ? 'uncertain' : 'failed'
         current.error = error instanceof Error ? error.message : '执行失败'
         this.saveWork(owner, current)
@@ -286,7 +289,7 @@ export abstract class WorkspaceScheduler {
       this.executing.delete(work.id)
       if (!this.closing) {
         const current = this.getWork(owner, work.id)
-        if (current.status === 'uncertain') {
+        if(current.status==='queued'&&current.resourceWait){this.recoverAfter.set(work.id,Date.now()+1000);this.retrySoon()}else if (current.status === 'uncertain') {
           const attempt = this.attempts.get(work.id) ?? 0
           this.attempts.set(work.id, attempt + 1)
           this.recoverAfter.set(work.id, Date.now() + Math.min(30_000, 500 * 2 ** Math.min(attempt, 6)))

+ 3 - 0
src/server/workspaceStore.ts

@@ -35,6 +35,7 @@ export const agentInput = z
     name,
     avatar: avatar.default(''),
     instructions: z.string().max(24_000).default(''),
+    execution:z.enum(['profile','computer']).default('profile'),
     canManageTeam: z.boolean().default(false),
     nodeId: z.string().default('local'),
     profile: z.string().min(1).max(256),
@@ -45,6 +46,7 @@ export const agentPatch = z
     name: name.optional(),
     avatar: avatar.optional(),
     instructions: z.string().max(24_000).optional(),
+    execution:z.enum(['profile','computer']).optional(),
     canManageTeam: z.boolean().optional(),
     archived: z.boolean().optional(),
   })
@@ -328,6 +330,7 @@ export class WorkspaceStore {
     if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
     return this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
+      if(patch.execution&&patch.execution!==(agent.execution??'profile')&&this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['running','waiting','uncertain'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再切换执行环境','agent_execution_busy')
       if (agent.remoteAgentId && Object.keys(patch).some(key => key !== 'archived'))
         throw new HttpError(409, '引用 Agent 的配置由远端管理', 'remote_agent_read_only')
       if (

+ 11 - 3
src/server/workspaceTeamTools.ts

@@ -11,7 +11,7 @@ import { assignmentInput, assignmentReview, assignmentUpdate, assignmentCancel,
 
 const uuid = z.string().uuid()
 const empty = z.object({}).strict()
-const createAgent = agentInput.omit({ canManageTeam: true }).extend({ requestId: uuid }).strict()
+const createAgent = agentInput.omit({ canManageTeam: true }).extend({ requestId: uuid,execution:z.enum(['profile','computer']).optional() }).strict()
 const createTeam = z.object({
   requestId: uuid,
   name: z.string().trim().min(1).max(100),
@@ -56,11 +56,16 @@ export const TEAM_TOOL_RULES = '你已获准在 Bot 模式组建团队。使用
 export class WorkspaceTeamTools {
   constructor(readonly store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly runtime: WorkspaceRuntime) {}
 
-  async requireAvailable(owner: string, agent: Pick<Agent, 'nodeId' | 'profile' | 'remoteAgentId'>): Promise<void> {
+  async requireAvailable(owner: string, agent: Pick<Agent, 'nodeId' | 'profile' | 'remoteAgentId' | 'execution'>): Promise<void> {
     this.nodes.requireSource(owner, agent)
     if ((agent.nodeId !== 'local' && !this.nodes.runnerTarget?.(owner,agent.nodeId)) || agent.remoteAgentId)
       throw new HttpError(409, '组队发起者需要使用与 Web 服务同机的基础 Agent;远程 Agent 可作为团队成员。', 'team_tools_local_required')
-    await requireTeamToolBridge(this.nodes.target(owner, agent.nodeId), agent.profile)
+    if(agent.execution==='computer'){
+      const target=this.nodes.runnerTarget?.(owner,agent.nodeId)
+      if(!target)throw new HttpError(409,'隔离电脑需要连接执行节点','computer_runner_required')
+      const response=await target.session.request('/api/computer/capabilities',{search:new URLSearchParams({profile:agent.profile})})
+      if(response.status!==200||JSON.parse(response.body.toString()).ready!==true)throw new HttpError(409,'执行节点尚未配置隔离电脑 Worker','computer_unavailable')
+    }else await requireTeamToolBridge(this.nodes.target(owner, agent.nodeId), agent.profile)
     this.nodes.requireSource(owner, agent)
   }
 
@@ -125,12 +130,15 @@ export class WorkspaceTeamTools {
     }
     if (toolId === 'workspace_create_agent') {
       const { requestId, ...body } = parse(createAgent, input)
+      body.execution??=agent.execution??'profile'
+      if(agent.execution==='computer'&&body.execution!=='computer')throw new HttpError(403,'隔离 Agent 创建的成员必须使用隔离电脑','computer_scope_escalation')
       this.nodes.requireSource(owner, body)
       const sources = await this.nodes.sources(owner)
       this.assertTurn(owner, workId)
       this.nodes.requireSource(owner, body)
       if (!sources.sources.some(s => s.nodeId === body.nodeId && s.profile === body.profile))
         throw new HttpError(409, '基础 Agent 当前不可用', 'source_unavailable')
+      if(body.execution==='computer'){await this.requireAvailable(owner,body);this.assertTurn(owner,workId)}
       return this.store.command(owner, requestId, { agentId: agent.id, toolId, body }, () => {
         if (this.store.list<Agent>(owner, 'agent').filter(a => !a.archived).length >= 100)
           throw new HttpError(409, '自动组队最多保留 100 个有效 Agent,请先复用或归档已有成员。', 'team_agent_limit')

+ 1 - 0
src/shared/runner.ts

@@ -24,5 +24,6 @@ export interface RunnerConfiguration {
   allowedProfiles: string[]
   artifactRoots: string[]
   allowInsecureLan?: boolean
+  computers?: {runtime:'docker'|'podman';imageId:string;python:string;hermesSource:string;hermesHome:string;maxConcurrent?:number}
   hermesCredentials?: {username:string;password:string}
 }

+ 2 - 0
src/shared/workspace.ts

@@ -5,6 +5,7 @@ export interface WorkspaceAgent {
   avatar: string
   instructions: string
   /** Explicit account-owner grant; absent on older records means disabled. */
+  execution?: 'profile' | 'computer'
   canManageTeam?: boolean
   teamAuthorizationVersion?: number
   createdByAgentId?: string
@@ -79,6 +80,7 @@ export interface WorkspaceFile {
   sender: 'user' | 'agent'
 }
 export interface WorkspaceMessage {
+  execution?:'profile'|'computer'
   id: string
   conversationId: string
   /** User-visible task scope. `taskId` below remains the scheduler turn ID. */

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

@@ -79,6 +79,11 @@ test('registers an execution node, downloads its private config and disables it'
   const panel=dialog.getByRole('region',{name:'执行节点设置'})
   await panel.getByRole('textbox',{name:'节点名称',exact:true}).fill('隔离测试 Mac')
   await panel.getByRole('textbox',{name:'允许的 Profile'}).fill('default')
+  await panel.getByRole('checkbox',{name:'启用隔离电脑 Worker'}).check()
+  await panel.getByRole('textbox',{name:'兼容镜像 ID'}).fill('sha256:'+'a'.repeat(64))
+  await panel.getByRole('textbox',{name:'Hermes Python 路径'}).fill('/fixture/venv/bin/python')
+  await panel.getByRole('textbox',{name:'Hermes 源码目录'}).fill('/fixture/hermes-agent')
+  await panel.getByRole('textbox',{name:'Hermes 配置目录'}).fill('/fixture/hermes')
   await panel.scrollIntoViewIfNeeded()
   await page.screenshot({path:testInfo.outputPath('runner-registration.png')})
   await panel.getByRole('button',{name:'注册节点',exact:true}).click()
@@ -90,7 +95,7 @@ test('registers an execution node, downloads its private config and disables it'
   const stream=await download.createReadStream();const chunks:Buffer[]=[]
   for await(const chunk of stream!)chunks.push(Buffer.from(chunk))
   const configuration=JSON.parse(Buffer.concat(chunks).toString())
-  expect(configuration).toMatchObject({protocol:1,allowedProfiles:['default'],artifactRoots:[]})
+  expect(configuration).toMatchObject({protocol:1,allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',python:'/fixture/venv/bin/python',hermesHome:'/fixture/hermes'}})
   expect(configuration.token.length).toBeGreaterThan(32)
   const result=await (await page.request.get('/api/app/admin/runners')).json()
   expect(JSON.stringify(result)).not.toContain(configuration.token)
@@ -102,3 +107,23 @@ test('registers an execution node, downloads its private config and disables it'
   await expect(panel).toContainText('已停用')
   expect((await (await page.request.get('/api/app/admin/runners')).json()).runners.find((r:any)=>r.id===configuration.runnerId).enabled).toBe(false)
 })
+
+test('explains isolated execution and keeps the edit form intact when a computer Runner is missing',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 page.locator('.desktop-sidebar .sidebar-create-trigger').click()
+  await page.getByRole('menuitem',{name:'新建 Bot',exact:true}).click()
+  const dialog=page.getByRole('dialog')
+  await dialog.getByRole('textbox',{name:'名称',exact:true}).fill('浏览器隔离成员')
+  await dialog.getByRole('combobox',{name:'执行环境',exact:true}).selectOption('computer')
+  await expect(dialog).toContainText('目前电脑不联网')
+  await dialog.getByRole('button',{name:'保存',exact:true}).click()
+  await expect(dialog.getByText(/隔离电脑需要连接执行节点/)).toBeVisible()
+  await expect(dialog).toBeVisible()
+  await expect(dialog.getByRole('combobox',{name:'执行环境',exact:true})).toHaveValue('computer')
+  await page.setViewportSize({width:390,height:844})
+  await dialog.getByRole('combobox',{name:'执行环境',exact:true}).scrollIntoViewIfNeeded()
+  await page.screenshot({path:testInfo.outputPath('computer-execution-mobile.png')})
+})

+ 126 - 0
tests/live/worker.live.test.ts

@@ -0,0 +1,126 @@
+// @vitest-environment node
+import {expect,it} from 'vitest'
+import {createServer} from 'node:http'
+import {mkdtemp,rm,access} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {HermesWorkerProcess} from '../../src/runner/worker/process'
+
+it.skipIf(!process.env.YAOYAO_HERMES_PYTHON)('uses the actual Hermes model loop with only the closed parent tool catalog',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-worker-live-'))
+  const requests:any[]=[],calls:any[]=[]
+  const server=createServer(async(req,res)=>{
+    let body='';for await(const chunk of req)body+=chunk
+    if(req.method==='GET'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({object:'list',data:[{id:'fixture-model',object:'model',context_length:65536}]}));return}
+    const input=JSON.parse(body||'{}');if(!Array.isArray(input.messages)){res.statusCode=404;res.end('{}');return}requests.push(input)
+    const called=input.messages.some((message:any)=>message.role==='tool')
+    const message=called?{role:'assistant',content:'隔离 Worker 已完成。'}:{role:'assistant',content:null,tool_calls:[{id:'fixture-call',type:'function',function:{name:'computer_shell',arguments:JSON.stringify({command:'printf fixture'})}},{id:'forbidden-native',type:'function',function:{name:'terminal',arguments:JSON.stringify({command:`printf escaped > ${home}/host-escape.txt`})}}]}
+    if(input.stream){
+      res.setHeader('Content-Type','text/event-stream')
+      res.end('data: '+JSON.stringify({id:'fixture-response',object:'chat.completion.chunk',model:'fixture-model',choices:[{index:0,delta:{...message,...(message.tool_calls?{tool_calls:message.tool_calls.map((tool:any,index:number)=>({...tool,index}))}:{})},finish_reason:message.tool_calls?'tool_calls':'stop'}]})+'\n\ndata: [DONE]\n\n')
+    }else{res.setHeader('Content-Type','application/json');res.end(JSON.stringify({id:'fixture-response',object:'chat.completion',model:'fixture-model',choices:[{index:0,message,finish_reason:message.tool_calls?'tool_calls':'stop'}],usage:{prompt_tokens:10,completion_tokens:10,total_tokens:20}}))}
+  })
+  await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve))
+  let worker:HermesWorkerProcess|undefined,diagnostics=''
+  try{
+    worker=new HermesWorkerProcess(process.env.YAOYAO_HERMES_PYTHON!,resolve('src/runner/worker/hermes_worker.py'),{diagnostic:true,mode:'run',home,hermesSource:process.env.YAOYAO_HERMES_SOURCE,model:{provider:'custom',api_mode:'chat_completions',base_url:`http://127.0.0.1:${(server.address() as {port:number}).port}/v1`,api_key:'fixture-private-key',model:'fixture-model'},sessionId:randomUUID(),taskId:randomUUID(),cwd:'/workspace',tools:[{name:'computer_shell',description:'fixture shell',inputSchema:{type:'object',properties:{command:{type:'string'}},required:['command'],additionalProperties:false}}],prompt:'执行当前测试工具后总结。',history:[]},text=>{diagnostics+=text})
+    worker.onTool=async(name,args)=>{calls.push({name,args});return {stdout:'fixture',exitCode:0}}
+    const complete=await worker.wait('complete',45000).catch(error=>{throw new Error(`${String(error)}\n${diagnostics}`)})
+    expect(complete.text).toBe('隔离 Worker 已完成。')
+    expect(calls,JSON.stringify(requests.flatMap(r=>r.messages.filter((m:any)=>m.role==='tool')))+'\n'+diagnostics).toEqual([{name:'computer_shell',args:{command:'printf fixture'}}])
+    await expect(access(join(home,'host-escape.txt'))).rejects.toMatchObject({code:'ENOENT'})
+    expect(requests.flatMap(input=>input.messages).find((message:any)=>message.tool_call_id==='forbidden-native')?.content).toMatch(/not authorized|does not exist/)
+    expect(requests.length).toBeGreaterThanOrEqual(2)
+    for(const request of requests)expect(request.tools.map((tool:any)=>tool.function.name)).toEqual(['computer_shell'])
+  }finally{await worker?.close();await new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()});await rm(home,{recursive:true,force:true})}
+},60000)
+
+it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)('runs Bot tasks and team creation through a real Hermes worker and a real isolated computer',async()=>{
+  const {writeFile,mkdir,readFile}=await import('node:fs/promises')
+  const {createApplication}=await import('../../src/server/app')
+  const {loadServerConfig}=await import('../../src/server/config')
+  const {RunnerAgent}=await import('../../src/runner/agent')
+  const request=(await import('supertest')).default
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-worker-platform-'))
+  const hermesHome=join(home,'hermes'),webHome=join(home,'web')
+  let runnerHome=join(home,'runner')
+  await Promise.all([hermesHome,runnerHome,webHome].map(path=>mkdir(path,{recursive:true})))
+  const commands:any[]=[]
+  let configuredCwd='/projects/worker-fixture'
+  const upstream=createServer(async(req,res)=>{
+    let body='';for await(const chunk of req)body+=chunk
+    const path=new URL(req.url!,'http://fixture').pathname
+    res.setHeader('Content-Type','application/json')
+    if(path==='/api/config'){res.end(JSON.stringify({terminal:{cwd:configuredCwd}}));return}
+    if(path==='/api/profiles'){res.end(JSON.stringify({profiles:[{name:'default'}]}));return}
+    if(req.method==='GET'){res.end(JSON.stringify(path.startsWith('/v1')?{data:[{id:'fixture-model',context_length:65536}]}:{ok:true}));return}
+    const input=JSON.parse(body||'{}')
+    if(!input.messages){res.statusCode=404;res.end('{}');return}
+    if(req.headers.authorization!=='Bearer fixture-private-key'){res.statusCode=401;res.end('{}');return}
+    commands.push(input)
+    const user=input.messages.filter((message:any)=>message.role==='user').at(-1)?.content??''
+    const team=String(user).includes('fixture:team'),slow=String(user).includes('fixture:slow')
+    const current=input.messages.slice(input.messages.findLastIndex((message:any)=>message.role==='user')+1)
+    const called=current.some((message:any)=>message.role==='tool')
+    const exported=current.some((message:any)=>message.role==='tool'&&message.name==='computer_export')
+    const message=called&&!team&&!exported?{role:'assistant',content:null,tool_calls:['result.txt','bulk.bin'].map(path=>({id:randomUUID(),type:'function',function:{name:'computer_export',arguments:JSON.stringify({path})}}))}:called?{role:'assistant',content:'隔离任务完成'}:{role:'assistant',content:null,tool_calls:[{id:randomUUID(),type:'function',function:{name:team?'workspace_create_agent':'computer_shell',arguments:JSON.stringify(team?{requestId:randomUUID(),name:'隔离创建成员',profile:'default'}:{command:slow?'sleep 30; printf forbidden > late-write.txt':'printf worker-proof > result.txt; head -c 2100000 /dev/zero > bulk.bin; id -u; pwd'})}}]}
+    if(input.stream){res.setHeader('Content-Type','text/event-stream');res.end('data: '+JSON.stringify({id:randomUUID(),object:'chat.completion.chunk',model:'fixture-model',choices:[{index:0,delta:{...message,...(message.tool_calls?{tool_calls:message.tool_calls.map((tool:any,index:number)=>({...tool,index}))}:{})},finish_reason:message.tool_calls?'tool_calls':'stop'}]})+'\n\ndata: [DONE]\n\n')}
+    else res.end(JSON.stringify({id:randomUUID(),object:'chat.completion',model:'fixture-model',choices:[{index:0,message,finish_reason:message.tool_calls?'tool_calls':'stop'}],usage:{prompt_tokens:10,completion_tokens:10,total_tokens:20}}))
+  })
+  await new Promise<void>(resolve=>upstream.listen(0,'127.0.0.1',resolve));const upstreamURL=`http://127.0.0.1:${(upstream.address() as {port:number}).port}`
+  await writeFile(join(hermesHome,'config.yaml'),`model:\n  provider: custom\n  default: fixture-model\n  base_url: ${upstreamURL}/v1\n  api_key: \${WORKER_FIXTURE_KEY}\n  api_mode: chat_completions\nterminal:\n  cwd: /projects/worker-fixture\n`,{mode:0o600})
+  await writeFile(join(hermesHome,'.env'),'WORKER_FIXTURE_KEY=fixture-private-key\n',{mode:0o600})
+  const app=createApplication({config:loadServerConfig({HERMES_YAOYAO_HOME:webHome,HERMES_YAOYAO_UPSTREAM:upstreamURL})})
+  const server=createServer(app.app.callback());await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve));app.config.port=(server.address() as {port:number}).port
+  const address=`http://127.0.0.1:${app.config.port}`,abort=new AbortController();let running:Promise<void>|undefined
+  try{
+    const bootstrap=await request(app.app.callback()).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).expect(200)
+    const setup=await request(app.app.callback()).post('/api/app/setup').set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',bootstrap.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')).set('X-CSRF-Token',bootstrap.body.csrfToken).send({username:'worker-fixture',password:'worker-fixture-password'}).expect(200)
+    const owner=setup.body.user.id,registered=app.runners.enroll(owner,{name:'Worker 验收节点',allowedProfiles:['default']})
+    const runnerConfig={protocol:1,serverURL:address,runnerId:registered.runner.id,token:registered.token,hermesURL:upstreamURL,allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:process.env.YAOYAO_COMPUTER_IMAGE!,python:process.env.YAOYAO_HERMES_PYTHON!,hermesSource:process.env.YAOYAO_HERMES_SOURCE!,hermesHome}}
+    if(process.env.YAOYAO_WORKER_RUNNER_BUNDLE){
+      const {spawn}=await import('node:child_process')
+      const path=join(home,'runner-config.json');await writeFile(path,JSON.stringify(runnerConfig),{mode:0o600})
+      runnerHome=join(home,'state',registered.runner.id)
+      const child=spawn(process.execPath,[process.env.YAOYAO_WORKER_RUNNER_BUNDLE,'--config',path],{stdio:'ignore'})
+      const stop=()=>{child.kill('SIGTERM')};abort.signal.addEventListener('abort',stop,{once:true})
+      running=new Promise<void>((resolve,reject)=>{child.once('error',reject);child.once('exit',code=>{abort.signal.removeEventListener('abort',stop);code===0?resolve():reject(new Error(`Runner bundle exited ${code}`))})})
+    }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 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 并汇报。'})
+    await expect.poll(()=>app.workspace.require<any>(owner,'run',run.id).status,{timeout:45000}).toMatch(/complete|failed/)
+    expect(app.workspace.require<any>(owner,'run',run.id).status,JSON.stringify(app.workspace.messages(owner,direct.id))).toBe('complete')
+    expect(await readFile(join(runnerHome,'computer-workspaces',worker.id,'result.txt'),'utf8')).toBe('worker-proof')
+    const message=app.workspace.messages(owner,direct.id).find(message=>message.role==='assistant')!
+    expect(message.attachments).toHaveLength(2)
+    const report=message.attachments.find(file=>file.name==='result.txt')!
+    expect(report).toBeDefined()
+    const download=await request(app.app.callback()).get(`/api/app/files/${report.id}/download`).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',setup.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')).expect(200)
+    expect(download.text).toBe('worker-proof')
+    const files=app.workspace.list<any>(owner,'file');expect(await readFile(files.find(file=>file.name==='result.txt').path,'utf8')).toBe('worker-proof')
+    const bulk=files.find(file=>file.name==='bulk.bin');expect((await readFile(bulk.path)).length).toBe(2100000)
+    const toolMessages=commands.flatMap(input=>input.messages.filter((message:any)=>message.role==='tool'))
+    expect(JSON.parse(toolMessages[0].content).stdout).toBe('1000\n/projects/worker-fixture\n')
+    configuredCwd='/projects/changed-fixture'
+    const configuration=await readFile(join(hermesHome,'config.yaml'),'utf8');await writeFile(join(hermesHome,'config.yaml'),configuration.replace('/projects/worker-fixture',configuredCwd))
+    const next=app.workspaceRuntime.send(owner,direct.id,{requestId:randomUUID(),content:'再次执行并使用新的工作目录。'})
+    await expect.poll(()=>app.workspace.require<any>(owner,'run',next.id).status,{timeout:45000}).toMatch(/complete|failed/)
+    expect(app.workspace.require<any>(owner,'run',next.id).status).toBe('complete')
+    expect(commands.flatMap(input=>input.messages).some((message:any)=>message.role==='tool'&&String(message.content).includes('/projects/changed-fixture'))).toBe(true)
+    const cancelling=app.workspaceRuntime.send(owner,direct.id,{requestId:randomUUID(),content:'fixture:slow'})
+    await expect.poll(()=>app.workspace.messages(owner,direct.id).some(message=>message.runId===cancelling.id&&message.tools.some(tool=>tool.name==='computer_shell')),{timeout:30000}).toBe(true)
+    await app.workspaceRuntime.stop(owner,cancelling.id)
+    await expect.poll(()=>app.workspace.require<any>(owner,'run',cancelling.id).status,{timeout:20000}).toBe('interrupted')
+    await expect(access(join(runnerHome,'computer-workspaces',worker.id,'late-write.txt'))).rejects.toMatchObject({code:'ENOENT'})
+    const manager=app.workspace.createAgent(owner,{name:'隔离组队者',profile:'default',execution:'computer',canManageTeam:true})
+    const managerChat=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===manager.id)!
+    const managed=app.workspaceRuntime.send(owner,managerChat.id,{requestId:randomUUID(),content:'fixture:team'})
+    await expect.poll(()=>app.workspace.require<any>(owner,'run',managed.id).status,{timeout:45000}).toMatch(/complete|failed/)
+    expect(app.workspace.require<any>(owner,'run',managed.id).status,JSON.stringify(app.workspace.messages(owner,managerChat.id))).toBe('complete')
+    expect(app.workspace.list<any>(owner,'agent').find(agent=>agent.name==='隔离创建成员')).toMatchObject({execution:'computer',canManageTeam:false,createdByAgentId:manager.id})
+  }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})}
+},120000)

+ 28 - 0
tests/server/computerArtifacts.test.ts

@@ -0,0 +1,28 @@
+// @vitest-environment node
+import {expect,it,vi} from 'vitest'
+import {mkdtempSync,rmSync} from 'node:fs'
+import {join} from 'node:path'
+import {tmpdir} from 'node:os'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {WorkspaceAssets} from '../../src/server/workspaceAssets'
+import type {WorkspaceNodes} from '../../src/server/workspaceGateway'
+it('never resolves a computer message path against native Hermes or the host filesystem',async()=>{
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-computer-artifacts-')),store=new WorkspaceStore(home),target=vi.fn()
+  const assets=new WorkspaceAssets(store,{target} as unknown as WorkspaceNodes,home)
+  try{
+    const agent=store.createAgent('owner',{name:'隔离',profile:'default',execution:'computer'})
+    const conversation=store.list<any>('owner','conversation')[0]
+    const message:any={id:crypto.randomUUID(),conversationId:conversation.id,seq:0,role:'assistant',execution:'computer',agentId:agent.id,content:'[host file](/Users/private/secret.txt)',reasoning:'',status:'complete',attachments:[],tools:[],createdAt:Date.now()}
+    store.saveMessage('owner',message)
+    await assets.archive('owner',message)
+    expect(target).not.toHaveBeenCalled()
+    store.updateAgent('owner',agent.id,{execution:'profile'})
+    await assets.archive('owner',message)
+    expect(target).not.toHaveBeenCalled()
+    const first=assets.publish('owner',message,agent,'report.txt',Buffer.from('guest output'))
+    expect(assets.publish('owner',message,agent,'report.txt',Buffer.from('guest output'))).toEqual(first)
+    await assets.archive('owner',message)
+    expect(message.attachments).toHaveLength(1)
+    expect(message.attachments[0]).not.toHaveProperty('path')
+  }finally{assets.close();store.close();rmSync(home,{recursive:true,force:true})}
+})

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

@@ -88,3 +88,13 @@ it('rejects changes of account identity and resource configuration for a persist
   for(const change of [{ownerKey:'other'},{memoryMiB:8192}])await expect(f.pool.acquire({...resource,...change},'changed',()=>{})).rejects.toMatchObject({code:'computer_owner_mismatch'})
   expect(f.provider.ensure).toHaveBeenCalledTimes(1)
 })
+it('applies a changed authoritative cwd only after an environment is released',async()=>{
+  const f=fixture();await f.pool.recover();const resource=spec(),lease=await f.pool.acquire(resource,'task',()=>{})
+  const updated={...resource,cwd:'/projects/new-location'}
+  await expect(f.pool.configure(updated,()=>{})).rejects.toMatchObject({code:'computer_busy'})
+  await f.pool.release(lease)
+  await f.pool.configure(updated,()=>{})
+  const next=await f.pool.acquire(updated,'next',()=>{})
+  expect(next.generation).toBeGreaterThan(lease.generation)
+  await f.pool.close()
+})

+ 35 - 0
tests/server/runner.test.ts

@@ -229,3 +229,38 @@ it.each([0o600,0o644])('runs the CLI with private configuration and rejects broa
     expect(output).not.toContain(config.token)
   }finally{if(child.exitCode===null&&child.signalCode===null){child.kill('SIGTERM');await exited}}
 },15000)
+it('refuses a computer gateway on a legacy Runner before any native session is opened',async()=>{
+  const target=hub.target('owner','local',{environmentId:randomUUID(),agentId:randomUUID(),ownerKey:'a'.repeat(64)})!
+  const gateway=new WorkspaceGateway(target,{workId:randomUUID(),authorize:()=>{}});gateways.push(gateway)
+  await expect(gateway.connect()).rejects.toMatchObject({code:'computer_unavailable'})
+  expect(rpc.some(call=>call.method==='session.create')).toBe(false)
+})
+it('binds chunked artifacts to one live connection, validates bytes and publishes exactly once',async()=>{
+  const {createHash}=await import('node:crypto'),bytes=Buffer.from('artifact payload'),id=randomUUID()
+  const publish=vi.fn(async(name:string,data:Buffer)=>({id:randomUUID(),name,size:data.length}))
+  const gateway=new WorkspaceGateway(target,{workId:randomUUID(),authorize:()=>{},publishArtifact:publish});gateways.push(gateway)
+  await gateway.connect()
+  const connectionId=[...(hub as any).connections.keys()][0]
+  const send=(body:object)=>machine('artifact',{connectionId,id,...body})
+  const metadata={phase:'begin',name:'report.txt',size:bytes.length,digest:createHash('sha256').update(bytes).digest('hex')}
+  expect((await send(metadata)).status).toBe(200)
+  expect((await send({phase:'finish'})).status).toBe(409)
+  expect((await send({phase:'append',index:0,data:bytes.toString('base64')})).status).toBe(200)
+  expect((await send({phase:'append',index:0,data:bytes.toString('base64')})).status).toBe(200)
+  expect((await send({phase:'append',index:0,data:Buffer.from('wrong').toString('base64')})).status).toBe(409)
+  const first=await (await send({phase:'finish'})).json()
+  expect(await (await send({phase:'finish'})).json()).toEqual(first)
+  expect(publish).toHaveBeenCalledExactlyOnceWith('report.txt',bytes)
+  expect((await machine('artifact',{...metadata,connectionId:randomUUID(),id:randomUUID()})).status).toBe(403)
+  auth.version++
+  expect((await send({phase:'finish'})).status).toBe(403)
+})
+it('keeps only stop operations available on an existing channel after task permission is revoked',async()=>{
+  let allowed=true
+  const gateway=new WorkspaceGateway(target,{workId:randomUUID(),authorize:()=>{if(!allowed)throw new Error('revoked')}});gateways.push(gateway)
+  await gateway.connect();const session=await gateway.rpc('session.create',{profile:'default'})
+  allowed=false
+  await expect(gateway.rpc('prompt.submit',{session_id:session.session_id,text:'forbidden'})).rejects.toMatchObject({code:'runner_command_not_admitted'})
+  expect(await gateway.rpc('session.interrupt',{session_id:session.session_id})).toEqual({ok:true})
+  expect(rpc.filter(call=>call.method==='prompt.submit')).toHaveLength(0)
+})

+ 28 - 0
tests/server/workspace.test.ts

@@ -6,6 +6,8 @@ import { join } from 'node:path'
 import { randomUUID } from 'node:crypto'
 import { WebSocketServer, type WebSocket } from 'ws'
 import { defaultAgentIdentity, encodeAgentAvatar, decodeAgentAvatar } from '../../src/shared/agentIdentity'
+import {HttpError} from '../../src/server/errors'
+import {WorkspaceAssets} from '../../src/server/workspaceAssets'
 import { WorkspaceStore } from '../../src/server/workspaceStore'
 import { WorkspaceRuntime, mentionedAgents } from '../../src/server/workspaceRuntime'
 import { WorkspaceNodes } from '../../src/server/workspaceGateway'
@@ -1216,3 +1218,29 @@ it('keeps active avatar state ahead of queued work and publishes only recent out
   store.saveRun(owner,run)
   expect(store.require<WorkspaceConversation>(owner,'conversation',c.id).avatarSignals).toEqual({})
 })
+
+it('queues an unsubmitted turn while computer capacity is unavailable and submits it once after recovery',async()=>{
+  const a=agent('等候电脑'),c=direct(a.id),target=nodes.target(owner,'local')
+  vi.spyOn(target.session,'request').mockRejectedValueOnce(new HttpError(429,'资源不足','computer_quota'))
+  const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'等待资源后执行'})
+  await vi.waitFor(()=>expect(store.list<any>(owner,'turn').find(work=>work.runId===run.id)?.resourceWait).toBe(true))
+  expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('queued')
+  await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('complete'),{timeout:5000})
+  expect(requests.filter(request=>request.method==='prompt.submit')).toHaveLength(1)
+})
+
+it('forwards a generated artifact to a new task without changing its original provenance',async()=>{
+  const a=agent('产物接收者'),c=direct(a.id)
+  const first=runtime.send(owner,c.id,{requestId:randomUUID(),content:'first'})
+  await finished(first.id)
+  const message=store.messages(owner,c.id).find(message=>message.role==='assistant')!
+  const assets=new WorkspaceAssets(store,nodes,home)
+  const file=assets.publish(owner,message,a,'review.txt',Buffer.from('verified artifact'))
+  const sent=runtime.dispatch(owner,c.id,{requestId:randomUUID(),content:'复核附件',fileIds:[file.id]},{agentId:a.id,kind:'task_review'})
+  await finished(sent.id)
+  const attached=requests.find(request=>request.method==='file.attach')!
+  expect(Buffer.from(attached.params.data_url.split(',')[1],'base64').toString()).toBe('verified artifact')
+  expect(store.require<any>(owner,'file',file.id).messageId).toBe(message.id)
+  expect(store.require<WorkspaceMessage>(owner,'message',sent.messageId).attachments.map(file=>file.id)).toEqual([file.id])
+  assets.close()
+})

+ 5 - 0
tests/server/workspaceTeamTools.test.ts

@@ -261,3 +261,8 @@ describe('loopback tool lease', () => {
     } finally { vi.useRealTimers() }
   })
 })
+it('isolated managers cannot create host members and a running Agent cannot change execution mode',async()=>{
+  store.put(owner,'agent',manager.id,{...manager,execution:'computer'})
+  await expect(call('create_agent',{requestId:randomUUID(),name:'host bypass',profile:'default',execution:'profile'})).rejects.toMatchObject({code:'computer_scope_escalation'})
+  expect(()=>store.updateAgent(owner,manager.id,{execution:'profile'})).toThrow('请先停止当前任务')
+})