Przeglądaj źródła

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

LSamien 1 miesiąc temu
rodzic
commit
fecc05b345

+ 9 - 2
desktop/main.mjs

@@ -100,7 +100,14 @@ else {
     event.preventDefault()
     event.preventDefault()
     if (closing) return
     if (closing) return
     closing = true; clearInterval(timer)
     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 () => {
   app.whenReady().then(async () => {
     const packageVersion = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).webVersion
     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)},
       encrypt:value=>{if(!safeStorage.isEncryptionAvailable())throw new Error('系统钥匙串不可用,无法保存执行节点凭据');return safeStorage.encryptString(value)},
       decrypt:bytes=>safeStorage.decryptString(bytes),
       decrypt:bytes=>safeStorage.decryptString(bytes),
       onState:message=>{log(`Runner: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('runner-status');if(item)item.label=message},
       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 runnerAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'执行节点操作未完成',detail:error.message})}}
     async function importRunner(){
     async function importRunner(){

+ 6 - 3
desktop/runner-manager.mjs

@@ -47,6 +47,7 @@ export class DesktopRunnerManager {
     if(this.child || this.stopping) return
     if(this.child || this.stopping) return
     const child = this.options.fork()
     const child = this.options.fork()
     this.child = child
     this.child = child
+    this.stopFailure = undefined
     // Drain diagnostics without persisting possible upstream or parser secrets.
     // Drain diagnostics without persisting possible upstream or parser secrets.
     child.stdout?.resume();child.stderr?.resume()
     child.stdout?.resume();child.stderr?.resume()
     this.publish('执行节点正在连接')
     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))
       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.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()
       resolve()
       if(this.child !== child) return
       if(this.child !== child) return
       this.child = null
       this.child = null
@@ -76,10 +78,10 @@ export class DesktopRunnerManager {
   async stopChild() {
   async stopChild() {
     this.stopping = true; clearTimeout(this.restartTimer)
     this.stopping = true; clearTimeout(this.restartTimer)
     const child = this.child
     const child = this.child
-    if(!child) return
+    if(!child){if(this.stopFailure)throw this.stopFailure;return}
     child.postMessage({type:'shutdown'})
     child.postMessage({type:'shutdown'})
     let timer
     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) }
     finally { clearTimeout(timer) }
     if(!child.finished) {
     if(!child.finished) {
       child.kill()
       child.kill()
@@ -88,6 +90,7 @@ export class DesktopRunnerManager {
     }
     }
     if(!child.finished) throw new Error('执行节点尚未确认退出,暂不启动替代进程')
     if(!child.finished) throw new Error('执行节点尚未确认退出,暂不启动替代进程')
     if(this.child === child) this.child = null
     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()) }
   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('未配置执行节点') }) }
   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 功能仍未完成。
 - 电脑 Provider 基础:实现私有容器、归属与约束校验、持久工作目录、guest 执行和取消时的停止确认。15 项 Docker/Podman 离线测试通过;真实 Docker 无网络环境的创建、画面读取、持久文件重建和取消写入验收通过,截图已核对,测试容器已清理。详见 `docs/computer-provider.md`。尚未接入任务资源租约和受控 Worker,VM 功能仍未完成。
 - 电脑租约基础:SQLite 保存持有者、递增代次、期限与停止状态;配额在启动前预占,未确认停止的资源保持占用,取消后退休旧容器 ID。资源池 6 项测试及接入资源池后的真实 Docker 生命周期测试通过。仍未将该资源池接入 Bot 任务和受控 Worker。
 - 电脑租约基础:SQLite 保存持有者、递增代次、期限与停止状态;配额在启动前预占,未确认停止的资源保持占用,取消后退休旧容器 ID。资源池 6 项测试及接入资源池后的真实 Docker 生命周期测试通过。仍未将该资源池接入 Bot 任务和受控 Worker。
 - 当前源码验证:类型检查、711 项全量测试通过;2 项真实环境测试默认按需跳过,已分别执行过真实 Hermes Runner 协议和真实 Docker 电脑/租约验收。所有电脑验收容器均已清理。整体目标仍在进行,下一步是受控 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 基础
 # 私有电脑 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 进程已停止。
 - 取消或无法确认的 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
   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。
 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.mjs.map
       - runner-config.mjs
       - runner-config.mjs
       - runner-config.mjs.map
       - runner-config.mjs.map
+      - hermes_worker.py
       - release.json
       - release.json
       - ui/**/*
       - ui/**/*
 asar: true
 asar: true

+ 1 - 1
package.json

@@ -14,7 +14,7 @@
   },
   },
   "scripts": {
   "scripts": {
     "dev": "cross-env NODE_ENV=development tsx watch src/server/index.ts",
     "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",
     "start": "cross-env NODE_ENV=production node dist-server/server/index.js",
     "typecheck": "vue-tsc -b && tsc --noEmit -p tsconfig.server.json",
     "typecheck": "vue-tsc -b && tsc --noEmit -p tsconfig.server.json",
     "test": "vitest run",
     "test": "vitest run",

+ 2 - 1
scripts/build-runner.mjs

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

+ 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 busy = ref(false), error = ref(''), notice = ref('')
 const name = ref(''), source = ref('local'), profiles = ref('default')
 const name = ref(''), source = ref('local'), profiles = ref('default')
 const serverURL = ref(window.location.origin), hermesURL = ref('http://127.0.0.1:9119')
 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>()
 const artifactRoots = ref(''), insecure = ref(false), configuration = ref<RunnerConfiguration>()
 
 
 async function refresh() {
 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(web.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(web.hostname)&&!insecure.value)throw new Error('远程节点请使用 HTTPS,或勾选可信局域网 HTTP')
     if(!['127.0.0.1','[::1]'].includes(local.hostname)||!['http:','https:'].includes(local.protocol)||local.username||local.password)throw new Error('Hermes 地址必须指向 Runner 所在电脑的回环地址')
     if(!['127.0.0.1','[::1]'].includes(local.hostname)||!['http:','https:'].includes(local.protocol)||local.username||local.password)throw new Error('Hermes 地址必须指向 Runner 所在电脑的回环地址')
     const allowedProfiles = [...new Set(profiles.value.split(/[\s,,]+/).filter(Boolean))]
     const allowedProfiles = [...new Set(profiles.value.split(/[\s,,]+/).filter(Boolean))]
+    if(computers.value&&(!/^sha256:[a-f0-9]{64}$/.test(computerImage.value)||[computerPython.value,hermesSource.value,hermesHome.value].some(path=>!path.startsWith('/'))))throw new Error('请填写镜像完整 ID 和执行电脑上的绝对路径')
     const result = await apiRequest<{runner:Runner;token:string}>('/api/app/admin/runners', {method:'POST',body:{name:name.value.trim(),sourceNodeId:source.value,allowedProfiles}})
     const result = await apiRequest<{runner:Runner;token:string}>('/api/app/admin/runners', {method:'POST',body:{name:name.value.trim(),sourceNodeId:source.value,allowedProfiles}})
-    configuration.value = {protocol:1,serverURL:web.origin,runnerId:result.runner.id,token:result.token,hermesURL:local.toString(),allowedProfiles,artifactRoots:artifactRoots.value.split('\n').map(p=>p.trim()).filter(Boolean),...(insecure.value?{allowInsecureLan:true}:{})}
+    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。'
     notice.value = '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。'
     await refresh()
     await refresh()
   })
   })
@@ -68,6 +70,16 @@ onMounted(()=>{void action(refresh)})
       <label>Runner 本机 Hermes 地址<input v-model="hermesURL" type="url" required :disabled="busy"></label>
       <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>允许导出的产物目录<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="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>
       <button class="solid-button" :disabled="busy || !name.trim() || !profiles.trim()">注册节点</button>
     </form>
     </form>
     <div v-else class="runner-setup">
     <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-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,.runner-panel label,.runner-setup { display: grid; gap: 8px; min-width: 0; }
 .runner-panel form { gap: 14px; }
 .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 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 .runner-check { display: flex; align-items: center; }
 .runner-panel code { overflow-wrap: anywhere; line-height: 1.7; }
 .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: '',
   name: '',
   avatar: '',
   avatar: '',
   instructions: '',
   instructions: '',
+  execution: 'profile' as 'profile'|'computer',
   canManageTeam: false,
   canManageTeam: false,
   source: '',
   source: '',
   memberIds: [] as string[],
   memberIds: [] as string[],
@@ -363,7 +364,8 @@ async function openDialog(kind: NonNullable<typeof dialog.value>) {
     name: '',
     name: '',
     avatar: kind === 'agent' ? encodeAgentAvatar(randomAgentIdentity('new-agent', 'Agent')) : '',
     avatar: kind === 'agent' ? encodeAgentAvatar(randomAgentIdentity('new-agent', 'Agent')) : '',
     instructions: '',
     instructions: '',
-    canManageTeam: false,
+    execution: 'profile' as 'profile'|'computer',
+  canManageTeam: false,
     source: '',
     source: '',
     memberIds: [],
     memberIds: [],
     memberRoles: {},
     memberRoles: {},
@@ -420,7 +422,7 @@ async function save() {
   busy.value = true
   busy.value = true
   error.value = ''
   error.value = ''
   try {
   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') {
     if (dialog.value === 'agent') {
       const [nodeId, profile] = JSON.parse(form.source)
       const [nodeId, profile] = JSON.parse(form.source)
       const result = await apiRequest<{ agent: Agent }>('/api/app/agents', {
       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" />
     <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
     <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
     <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
     <RemoteAgentPicker v-if="remotePickerOpen && !auth.isBotOnly" @close="remotePickerOpen = false" @added="remoteAdded" />
     <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">
       <form @submit.prevent="save">
         <header>
         <header>
           <h2>
           <h2>
@@ -730,9 +732,17 @@ onBeforeUnmount(() => {
             "
             "
           />
           />
         </label>
         </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">
         <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>
           <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>
         </label>
         <button v-if="!isAgentDialog && !auth.isBotOnly" type="button" @click="remotePickerOpen = true">添加远程 Agent</button>
         <button v-if="!isAgentDialog && !auth.isBotOnly" type="button" @click="remotePickerOpen = true">添加远程 Agent</button>
         <fieldset v-if="selectedPreset" class="preset-role-mapping">
         <fieldset v-if="selectedPreset" class="preset-role-mapping">
@@ -810,7 +820,7 @@ onBeforeUnmount(() => {
           </button>
           </button>
         </footer>
         </footer>
       </form>
       </form>
-    </dialog>
+    </dialog></Teleport>
   </WorkspaceShell>
   </WorkspaceShell>
 </template>
 </template>
 <style scoped>
 <style scoped>

+ 45 - 9
src/runner/agent.ts

@@ -1,7 +1,9 @@
 import { randomUUID, createHash } from 'node:crypto'
 import { randomUUID, createHash } from 'node:crypto'
 import { DatabaseSync } from 'node:sqlite'
 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 { 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 { HttpError } from '../server/errors.js'
 import { UpstreamClient } from '../server/upstream.js'
 import { UpstreamClient } from '../server/upstream.js'
 import { UpstreamServiceSession } from '../server/localAuth.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'
 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'])
 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}}
 interface Lease {connectionId:string;profile:string;lease:WorkspaceToolLease;controller:AbortController;session:{runtimeId:string;storedId:string}}
 
 
 export class RunnerAgent {
 export class RunnerAgent {
@@ -26,6 +28,7 @@ export class RunnerAgent {
   private closedLeases=new Map<string,number>()
   private closedLeases=new Map<string,number>()
   private inflight=new Map<string,Promise<unknown>>()
   private inflight=new Map<string,Promise<unknown>>()
   private db:DatabaseSync
   private db:DatabaseSync
+  private computers?:ComputerRuntime
   private target:GatewayTarget
   private target:GatewayTarget
   private controlTransport=new LoopbackTransport()
   private controlTransport=new LoopbackTransport()
   private serverEpoch?:string
   private serverEpoch?:string
@@ -39,6 +42,7 @@ export class RunnerAgent {
     this.target={url:hermes,client,session:new UpstreamServiceSession(client,()=>config.hermesCredentials)}
     this.target={url:hermes,client,session:new UpstreamServiceSession(client,()=>config.hermesCredentials)}
     this.db=new DatabaseSync(join(home,'runner-commands.sqlite3'))
     this.db=new DatabaseSync(join(home,'runner-commands.sqlite3'))
     this.db.exec('CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,state TEXT NOT NULL,result TEXT,created INTEGER NOT NULL)')
     this.db.exec('CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,state TEXT NOT NULL,result TEXT,created INTEGER NOT NULL)')
+    if(config.computers)this.computers=new ComputerRuntime(this.db,config,home)
     this.compactReceipts()
     this.compactReceipts()
   }
   }
   private async api(path:string,body?:unknown):Promise<any> {
   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'
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(25000)]),
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(25000)]),
-      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1',...(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)}),
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     })
     if(!response.ok)throw new HttpError(response.status,`执行节点连接被拒绝(HTTP ${response.status})`,response.status===403?'runner_unauthorized':'runner_request_failed')
     if(!response.ok)throw new HttpError(response.status,`执行节点连接被拒绝(HTTP ${response.status})`,response.status===403?'runner_unauthorized':'runner_request_failed')
@@ -66,7 +70,7 @@ export class RunnerAgent {
     this.connections.delete(id)
     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()}
     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)}}
     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> {
   private disconnect():Promise<void> {
     if(this.disconnecting)return this.disconnecting
     if(this.disconnecting)return this.disconnecting
@@ -84,6 +88,19 @@ export class RunnerAgent {
     if(!this.active||!this.connected)throw new Error('执行节点已断开')
     if(!this.active||!this.connected)throw new Error('执行节点已断开')
     if(command.kind==='http') {
     if(command.kind==='http') {
       const path=String(p.path),search=new URLSearchParams(String(p.search??''))
       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(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(!['/api/status','/api/profiles'].includes(path))this.requireProfile(search.get('profile')??'default')
       if(path==='/api/files/download') {
       if(path==='/api/files/download') {
@@ -97,8 +114,13 @@ export class RunnerAgent {
     const connectionId=String(p.connectionId??'')
     const connectionId=String(p.connectionId??'')
     if(command.kind==='gateway.open') {
     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('执行通道无效或已达上限')
       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)
       this.connections.set(connectionId,connection)
       gateway.onEvent=(frame:GatewayFrame)=>{
       gateway.onEvent=(frame:GatewayFrame)=>{
         if(frame.session_id&&!connection.sessions.has(frame.session_id))return
         if(frame.session_id&&!connection.sessions.has(frame.session_id))return
@@ -113,7 +135,8 @@ export class RunnerAgent {
     if(command.kind==='gateway.rpc') {
     if(command.kind==='gateway.rpc') {
       const connection=this.connections.get(connectionId),method=String(p.method),params=p.params as Record<string,unknown>
       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(!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')
       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))
       if(method==='prompt.submit')connection.running.add(String(params.session_id))
       const result=await connection.gateway.rpc(method,params)
       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)
       const source=[...this.connections].find(([,c])=>c.sessions.get(session?.runtimeId)===p.profile)
       if(!source)throw new HttpError(403,'工具会话不属于此节点','runner_session_forbidden')
       if(!source)throw new HttpError(403,'工具会话不属于此节点','runner_session_forbidden')
       const controller=new AbortController()
       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,
       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('工具授权已失效')},
         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,
         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()=>{
     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')
       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)
       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})
       .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))
       .finally(()=>this.inflight.delete(command.id))
     this.inflight.set(command.id,pending);return pending
     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))
           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 { 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 { mkdir, realpath, chmod } from 'node:fs/promises'
-import { join, resolve } from 'node:path'
+import { join, resolve, posix } from 'node:path'
 import { z } from 'zod'
 import { z } from 'zod'
 
 
 export const COMPUTER_WORKSPACE = '/home/cua/workspace'
 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'
 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 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 mib=1024*1024
-const execAsync=promisify(execFile)
 export interface CommandResult {stdout:string;stderr:string}
 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({
 const schema=z.object({
   id:z.string().uuid(),ownerKey:z.string().min(1).max(256),
   id:z.string().uuid(),ownerKey:z.string().min(1).max(256),
   imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),
   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),
   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),
   pids:z.number().int().min(64).max(1024).default(512),
 }).strict()
 }).strict()
 export type ComputerSpecification=z.input<typeof schema>
 export type ComputerSpecification=z.input<typeof schema>
@@ -30,7 +38,7 @@ export interface ComputerProvider {
   ensure(spec:ComputerSpecification,authorize:()=>void):Promise<ComputerState>
   ensure(spec:ComputerSpecification,authorize:()=>void):Promise<ComputerState>
   stop(spec:ComputerSpecification):Promise<void>
   stop(spec:ComputerSpecification):Promise<void>
   remove(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>
   health(spec:ComputerSpecification,authorize:()=>void):Promise<unknown>
 }
 }
 export class ComputerError extends Error { constructor(readonly code:string,message:string){super(message)} }
 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 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 noPrivilege=(host.SecurityOpt??[]).some((value:string)=>/^no-new-privileges(?::true)?$/.test(value))
     const ports=Object.entries(host.PortBindings??{}),mounts=detail.Mounts??[]
     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
     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'
       ||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'
       ||host.NetworkMode!=='none'||host.PidMode==='host'||host.UsernsMode==='host'||host.UTSMode==='host'
       ||!noPrivilege||(host.SecurityOpt??[]).some((value:string)=>/unconfined/.test(value))
       ||!noPrivilege||(host.SecurityOpt??[]).some((value:string)=>/unconfined/.test(value))
       ||JSON.stringify(capabilities(host.CapAdd))!==JSON.stringify(expectedCaps)||!capabilities(host.CapDrop).includes('ALL')
       ||JSON.stringify(capabilities(host.CapAdd))!==JSON.stringify(expectedCaps)||!capabilities(host.CapDrop).includes('ALL')
       ||(host.Devices??[]).length||(host.DeviceRequests??[]).length||host.RestartPolicy?.Name!=='no'
       ||(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)
       ||ports.length!==0)
       throw new ComputerError('computer_configuration_unsafe','电脑实例的隔离、资源或数据目录不符合要求,请先停止并核对')
       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),
       '--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',
       '--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']:[]),
       ...(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]
       '--env',`VNC_PW=${randomBytes(24).toString('base64url')}`,spec.imageId]
   }
   }
   async ensure(value:ComputerSpecification,authorize:()=>void):Promise<ComputerState> {
   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.
       // 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)
     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','电脑命令无效或过长')
     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()=>{
     return this.serial(spec,async()=>{
@@ -184,7 +193,7 @@ export class ContainerComputerProvider implements ComputerProvider {
       if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
       if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
       options.authorize()
       options.authorize()
       try {
       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
         options.authorize();return result
       }catch(error){
       }catch(error){
         // Killing a docker exec client alone cannot prove its guest process stopped.
         // 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}
       }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)}
   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>{
   use<T>(lease:ComputerLease,action:(context:{signal:AbortSignal;authorize():void})=>Promise<T>):Promise<T>{
     this.assert(lease)
     this.assert(lease)
@@ -79,10 +92,17 @@ export class ComputerPool {
     return pending
     return pending
   }
   }
   release(lease:ComputerLease):Promise<void>{
   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)
     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)})
     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){
   private invalidate(expected:Entry){
     const entry=this.get(expected.spec.id)
     const entry=this.get(expected.spec.id)
     if(!entry||entry.lease?.id!==expected.lease?.id||entry.status==='free')return
     if(!entry||entry.lease?.id!==expected.lease?.id||entry.status==='free')return
@@ -116,5 +136,6 @@ export class ComputerPool {
     this.closing=true
     this.closing=true
     for(const entry of this.rows())if(entry.status!=='free')this.invalidate(entry)
     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)!))))
     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'),
   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),
   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([]),
   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(),
   allowInsecureLan:z.boolean().optional(),hermesCredentials:z.object({username:z.string().max(256),password:z.string().max(4096)}).strict().optional(),
 }).strict()
 }).strict()
 export function parseRunnerConfiguration(value:unknown):RunnerConfiguration {
 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()}
   try{await agent.run(controller.signal)}finally{instance.release()}
 }catch(error){
 }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`)
   else process.stderr.write(`${error instanceof Error?error.message:'执行节点启动失败'}\n`)
   process.exitCode=1
   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)
   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)
   workspaceNodes.sourceAllowed = (owner, nodeId, profile) => auth.canUseSource(owner, nodeId, profile)
   const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
   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)
   const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0)
   workspaceRuntime.onTeamCreated = (owner, team) => {
   workspaceRuntime.onTeamCreated = (owner, team) => {
     try { push.setGroupSubscription(owner, team.id, true, team.lastSeq) } catch { /* Optional notifications do not undo a 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)
   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.onMessage = (owner, message) => workspaceAssets.archive(owner, message)
   workspaceRuntime.onNotify = (owner, c, run, message, interaction) => {
   workspaceRuntime.onNotify = (owner, c, run, message, interaction) => {
     if (c.kind === 'group' && !push.isGroupSubscribed(owner,c.id)) return
     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'),
 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()
   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 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>}> }
 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. */
 /** Outbound-only machine transport. Browser auth and machine auth remain separate. */
 export class RunnerHub {
 export class RunnerHub {
   readonly epoch=randomUUID()
   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 retired = new Map<string,Set<string>>()
   private pending = new Map<string,Map<string,Pending>>()
   private pending = new Map<string,Map<string,Pending>>()
   private connections = new Map<string,GatewayConnection>()
   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 leases = new Map<string,Lease>()
   private watchdog: ReturnType<typeof setInterval>
   private watchdog: ReturnType<typeof setInterval>
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly local:GatewayTarget) {
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly local:GatewayTarget) {
     this.watchdog=setInterval(()=>{
     this.watchdog=setInterval(()=>{
       for(const [id,state] of this.online) if(Date.now()-state.seen>35_000)this.disconnect(id)
       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()
     },5000);this.watchdog.unref()
   }
   }
   records():RunnerRecord[] {return this.store.list('_system','runner')}
   records():RunnerRecord[] {return this.store.list('_system','runner')}
@@ -46,7 +48,7 @@ export class RunnerHub {
   }
   }
   summary(record:RunnerRecord) {
   summary(record:RunnerRecord) {
     const {tokenHash:_secret,sourceOwner:_owner,...publicRecord}=record
     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) {
   remove(id:string) {
     const record=this.store.require<RunnerRecord>('_system','runner',id)
     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?.()
       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))
     const record=this.records().find(r=>r.enabled&&r.sourceNodeId===sourceNodeId&&(r.sourceOwner==='_system'||r.sourceOwner===owner))
     if(!record)return undefined
     if(!record)return undefined
     const requireProfile=(profile:string)=>{
     const requireProfile=(profile:string)=>{
       if(!this.auth.canUseSource(owner,sourceNodeId,profile)||!record.allowedProfiles.includes(profile))throw new HttpError(403,'执行节点未授权这个基础 Profile','runner_profile_forbidden')
       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>=>{
     const requestHTTP=async(path:string,options:UpstreamRequestOptions={}):Promise<UpstreamResponse>=>{
+      requireComputer()
       const profile=options.search?.get('profile')??'default'
       const profile=options.search?.get('profile')??'default'
       if(path!=='/api/profiles'&&path!=='/api/status')requireProfile(profile)
       if(path!=='/api/profiles'&&path!=='/api/status')requireProfile(profile)
       const version=this.auth.pushAuthorizationVersion(owner)
       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 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(!valid())throw new HttpError(403,'读取权限已失效','runner_authorization_revoked')
       if(path==='/api/profiles') {
       if(path==='/api/profiles') {
         const parsed=JSON.parse(Buffer.from(result.body,'base64').toString())
         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 {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')}},
     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 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
           let closed=false
           return {rpc:async(method,params)=>{
           return {rpc:async(method,params)=>{
             if(closed)throw new Error('Runner gateway closed')
             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})
             return this.request(record.id,'gateway.rpc',{connectionId:id,method,params})
           },close:()=>{
           },close:()=>{
             closed=true;this.connections.delete(id)
             closed=true;this.connections.delete(id)
@@ -112,7 +120,7 @@ export class RunnerHub {
           }}
           }}
         },
         },
         lease:async(options)=>{
         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()})
           const id=randomUUID();this.leases.set(id,{runnerId:record.id,input:options,calls:new Map()})
           let disposed=false
           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(()=>{})}
           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 {
   middleware():Koa.Middleware {
     return async(ctx,next)=>{
     return async(ctx,next)=>{
-      const match=/^\/api\/runner\/v1\/([0-9a-f-]{36})\/(poll|admit|result|event|tool)$/.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()
       if(!match)return next()
       const record=this.store.get<RunnerRecord>('_system','runner',match[1]!),token=ctx.get('authorization').replace(/^Bearer /,'')
       const record=this.store.get<RunnerRecord>('_system','runner',match[1]!),token=ctx.get('authorization').replace(/^Bearer /,'')
       if(ctx.get('origin')||!record?.enabled||!ctx.get('authorization').startsWith('Bearer ')||!timingSafeEqual(Buffer.from(record.tokenHash),Buffer.from(hash(token))))
       if(ctx.get('origin')||!record?.enabled||!ctx.get('authorization').startsWith('Bearer ')||!timingSafeEqual(Buffer.from(record.tokenHash),Buffer.from(hash(token))))
@@ -152,7 +160,7 @@ export class RunnerHub {
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
       }
       }
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
-      const state=this.online.get(record.id)??{instance,seen:Date.now(),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')
       ctx.set('Cache-Control','no-store')
       if(match[2]==='poll') {
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')
@@ -173,10 +181,40 @@ export class RunnerHub {
       let body:any
       let body:any
       try {body=JSON.parse(Buffer.concat(chunks).toString())}catch{throw new HttpError(400,'JSON 无效','invalid_json')}
       try {body=JSON.parse(Buffer.concat(chunks).toString())}catch{throw new HttpError(400,'JSON 无效','invalid_json')}
       if(!body||typeof body!=='object'||Array.isArray(body))throw new HttpError(400,'请求必须是对象','invalid_json')
       if(!body||typeof body!=='object'||Array.isArray(body))throw new HttpError(400,'请求必须是对象','invalid_json')
+      if(match[2]==='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') {
       if(match[2]==='admit') {
         const pending=this.pending.get(record.id)?.get(body.id),command=pending?.command
         const pending=this.pending.get(record.id)?.get(body.id),command=pending?.command
         let allowed=!!command&&command.expiresAt>Date.now()&&pending!.valid()
         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') {
         if(command?.kind==='lease.create'||command?.kind==='lease.bind') {
           const lease=this.leases.get(String(command.payload.leaseId))
           const lease=this.leases.get(String(command.payload.leaseId))
           try {if(lease?.runnerId!==record.id)allowed=false;else lease.input.assertActive()}catch{allowed=false}
           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 { HttpError } from './errors.js'
 import { parse, type WorkspaceStore } from './workspaceStore.js'
 import { parse, type WorkspaceStore } from './workspaceStore.js'
 import type { WorkspaceRuntime } from './workspaceRuntime.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'
 import type { AgentGoal, AgentAssignment, TaskDelivery, TaskOrigin } from '../shared/agentTasks.js'
 
 
 const terminalRun = (status: string) => ['complete', 'failed', 'interrupted'].includes(status)
 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 }
         catch { assignment.status = 'blocked';assignment.result = '负责成员的基础 Profile 已撤权';this.saveAssignment(owner, assignment);continue }
         this.store.atomic(() => {
         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 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,
           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' })
             { agentId: goal.coordinatorId, assignmentId: assignment.id, targetAgentId: assignment.agentId, kind: 'assignment' })
           assignment.runId = run.id;assignment.attempt++;assignment.status = 'running';this.saveAssignment(owner, 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.atomic(() => {
       this.store.put(owner, 'task-delivery', id, delivery)
       this.store.put(owner, 'task-delivery', id, delivery)
       const run = this.runtime.dispatch(owner, source!.id,
       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,
         { agentId: goal.coordinatorId, kind: kind === 'review' ? 'task_review' : 'task_result', instruction,
           taskReference:{conversationId:goal.conversationId,taskId:goal.id} })
           taskReference:{conversationId:goal.conversationId,taskId:goal.id} })
       delivery!.runId = run.id;delivery!.status = 'delivered';this.store.put(owner, 'task-delivery', id, delivery)
       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 { randomUUID, createHash } from 'node:crypto'
 import { mkdirSync, writeFileSync } from 'node:fs'
 import { mkdirSync, writeFileSync } from 'node:fs'
 import { join, basename } from 'node:path'
 import { join, basename } from 'node:path'
+import {HttpError} from './errors.js'
 import { lookup } from 'mime-types'
 import { lookup } from 'mime-types'
 import { WorkspaceStore } from './workspaceStore.js'
 import { WorkspaceStore } from './workspaceStore.js'
 import { WorkspaceNodes } from './workspaceGateway.js'
 import { WorkspaceNodes } from './workspaceGateway.js'
@@ -8,6 +9,7 @@ import type { WorkspaceAgent, WorkspaceFile, WorkspaceMessage } from '../shared/
 export interface StoredWorkspaceFile extends WorkspaceFile {
 export interface StoredWorkspaceFile extends WorkspaceFile {
   path: string
   path: string
   digest?: string
   digest?: string
+  sourceNodeId?:string
 }
 }
 export function publicFile(f: StoredWorkspaceFile): WorkspaceFile {
 export function publicFile(f: StoredWorkspaceFile): WorkspaceFile {
   const { path: _path, digest: _digest, ...result } = f
   const { path: _path, digest: _digest, ...result } = f
@@ -80,6 +82,22 @@ export class WorkspaceAssets {
     readonly nodes: WorkspaceNodes,
     readonly nodes: WorkspaceNodes,
     readonly home: string,
     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> {
   async archive(owner: string, message: WorkspaceMessage): Promise<void> {
     if (!message.agentId) return
     if (!message.agentId) return
     const agent = this.store.require<WorkspaceAgent>(owner, 'agent', message.agentId)
     const agent = this.store.require<WorkspaceAgent>(owner, 'agent', message.agentId)
@@ -114,6 +132,8 @@ export class WorkspaceAssets {
     remoteAgentId?: string,
     remoteAgentId?: string,
   ): Promise<void> {
   ): Promise<void> {
     if (this.stopped) return
     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>()
     const paths = new Set<string>()
     for (const m of text.matchAll(
     for (const m of text.matchAll(
       /(?:MEDIA:\s*|\]\(<?|"(?:path|file_path|output_path)"\s*:\s*")((?:\/|~\/)[^\n"<>)]{1,4096})/g,
       /(?: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 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 { readFileSync, writeFileSync } from 'node:fs'
 import { join } from 'node:path'
 import { join } from 'node:path'
 import { UpstreamClient, CookieJar } from './upstream.js'
 import { UpstreamClient, CookieJar } from './upstream.js'
@@ -20,6 +20,7 @@ export interface WorkspaceNode {
   fingerprint?: string
   fingerprint?: string
   deviceId?: string
   deviceId?: string
 }
 }
+export interface GatewayExecutionScope {workId:string;cleanupOnly?:boolean;sessionId?:string;authorize():void;publishArtifact?(name:string,bytes:Buffer):Promise<unknown>}
 export interface GatewayTarget {
 export interface GatewayTarget {
   url: URL
   url: URL
   client: UpstreamClient
   client: UpstreamClient
@@ -27,7 +28,8 @@ export interface GatewayTarget {
   pairedToken?: string
   pairedToken?: string
   runner?: {
   runner?: {
     id: string
     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>
     lease(input:import('./workspaceToolLease.js').LeaseInput):Promise<import('./workspaceToolLease.js').WorkspaceToolLease>
   }
   }
 }
 }
@@ -37,7 +39,7 @@ export interface GatewayFrame {
   payload?: Record<string, any>
   payload?: Record<string, any>
 }
 }
 export class WorkspaceNodes {
 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
   sourceAllowed: (owner: string, nodeId: string, profile: string) => boolean = () => true
   requireSource(owner: string, agent: { nodeId: string; profile: string }): void {
   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')
     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
     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)
     const base = this.target(owner, agent.nodeId)
     if (!agent.remoteAgentId) return base
     if (!agent.remoteAgentId) return base
     if (!base.pairedToken) throw new HttpError(409, '引用远端 Agent 需要扫码子节点', 'paired_node_required')
     if (!base.pairedToken) throw new HttpError(409, '引用远端 Agent 需要扫码子节点', 'paired_node_required')
@@ -280,9 +288,9 @@ export class WorkspaceGateway {
   >()
   >()
   onEvent: (frame: GatewayFrame) => void = () => {}
   onEvent: (frame: GatewayFrame) => void = () => {}
   onDisconnect: () => void = () => {}
   onDisconnect: () => void = () => {}
-  constructor(readonly target: GatewayTarget) {}
+  constructor(readonly target: GatewayTarget,readonly scope?:GatewayExecutionScope) {}
   async connect(): Promise<void> {
   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) {
     if (this.target.pairedToken) {
       this.paired = new WorkspacePairedChannel(this.target.url, this.target.pairedToken, this.target.client.fetchImpl,
       this.paired = new WorkspacePairedChannel(this.target.url, this.target.pairedToken, this.target.client.fetchImpl,
         frame => this.onEvent(frame), () => this.onDisconnect())
         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 { PushCoordinator } from './pushCoordinator.js'
 import type { LocalAuthStore } from './localAuth.js'
 import type { LocalAuthStore } from './localAuth.js'
 import type {
 import type {
+  WorkspaceMessage,
   WorkspaceAgent,
   WorkspaceAgent,
   WorkspaceConversation,
   WorkspaceConversation,
   WorkspaceRun,
   WorkspaceRun,
@@ -49,9 +50,11 @@ export function workspaceRouter(
       const row = store.db
       const row = store.db
         .prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='file' AND rowid=?")
         .prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='file' AND rowid=?")
         .get(user, Number(id))
         .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) => {
   router.get('/api/app/capabilities', (ctx) => {
     owner(ctx)
     owner(ctx)
@@ -98,7 +101,7 @@ export function workspaceRouter(
     if (!sources.sources.some((s) => s.nodeId === input.nodeId && s.profile === input.profile))
     if (!sources.sources.some((s) => s.nodeId === input.nodeId && s.profile === input.profile))
       throw new HttpError(409, '基础 Agent 当前不可用', 'source_unavailable')
       throw new HttpError(409, '基础 Agent 当前不可用', 'source_unavailable')
     nodes.requireSource(user, input)
     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')
     if (auth.pushAuthorizationVersion(user) !== authorization) throw new HttpError(401,'账号授权已变化,请重新登录','session_revoked')
     ctx.body = { agent: store.agentSummary(store.createAgent(user, input)) }
     ctx.body = { agent: store.agentSummary(store.createAgent(user, input)) }
     ctx.status = 201
     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 user = owner(ctx), agent = store.require<WorkspaceAgent>(user, 'agent', ctx.params.id), input = body(ctx)
     const authorization = auth.pushAuthorizationVersion(user)
     const authorization = auth.pushAuthorizationVersion(user)
     nodes.requireSource(user, agent)
     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')
     if (auth.pushAuthorizationVersion(user) !== authorization) throw new HttpError(401,'账号授权已变化,请重新登录','session_revoked')
     nodes.requireSource(user, agent)
     nodes.requireSource(user, agent)
     ctx.body = { agent: store.agentSummary(store.updateAgent(user, ctx.params.id, input)) }
     ctx.body = { agent: store.agentSummary(store.updateAgent(user, ctx.params.id, input)) }
@@ -374,7 +377,7 @@ export function workspaceRouter(
         ids.map((id) => [
         ids.map((id) => [
           id,
           id,
           files
           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) => ({
             .map((f, ordinal) => ({
               itemId: libraryId(owner(ctx), f.id),
               itemId: libraryId(owner(ctx), f.id),
               messageId: id,
               messageId: id,
@@ -406,6 +409,7 @@ export function workspaceRouter(
       )
       )
       ctx.set('Accept-Ranges', 'bytes')
       ctx.set('Accept-Ranges', 'bytes')
       ctx.set('Cache-Control', 'private, no-store')
       ctx.set('Cache-Control', 'private, no-store')
+      if(size===0){ctx.body=Buffer.alloc(0);return}
       const range = ctx.get('range'),
       const range = ctx.get('range'),
         match = /^bytes=(\d*)-(\d*)$/.exec(range)
         match = /^bytes=(\d*)-(\d*)$/.exec(range)
       let start = 0,
       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 { WorkspaceNodes, WorkspaceGateway, type GatewayFrame } from './workspaceGateway.js'
 import { HttpError } from './errors.js'
 import { HttpError } from './errors.js'
 import { applyWorkingDirectory, configuredWorkingDirectory } from './sessionWorkingDirectory.js'
 import { applyWorkingDirectory, configuredWorkingDirectory } from './sessionWorkingDirectory.js'
+import {type StoredWorkspaceFile} from './workspaceAssets.js'
 import { UploadStore } from './uploads.js'
 import { UploadStore } from './uploads.js'
 import type {
 import type {
   WorkspaceAgent as Agent,
   WorkspaceAgent as Agent,
@@ -24,6 +25,7 @@ type Run = WorkspaceRun
 
 
 export interface WorkspaceBinding {
 export interface WorkspaceBinding {
   runnerId?: string
   runnerId?: string
+  execution?:string
   remoteAgentId?: string
   remoteAgentId?: string
   id: string
   id: string
   nodeId: string
   nodeId: string
@@ -60,6 +62,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   private live = new Map<string, LiveTurn>()
   private live = new Map<string, LiveTurn>()
   readonly teamTools: WorkspaceTeamTools
   readonly teamTools: WorkspaceTeamTools
   readonly tasks: WorkspaceTaskCoordinator
   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 = () => {}
   onTeamCreated: (owner: string, team: Conversation) => void = () => {}
   constructor(store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly uploads: UploadStore, userActive: (owner: string) => boolean = () => true,
   constructor(store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly uploads: UploadStore, userActive: (owner: string) => boolean = () => true,
     readonly authorizationVersion: (owner: string) => number = () => 0) {
     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))
     this.nodes.requireSource(owner, this.store.require<Agent>(owner, 'agent', source.agentId))
     return this.submit(owner, conversationId, input, source)
     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?: {
   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}
     agentId: string; assignmentId?: string; targetAgentId?: string; kind: NonNullable<Run['triggerKind']>; instruction?: string; taskReference?: {conversationId:string;taskId:string}
   }): Run {
   }): Run {
@@ -96,7 +100,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       }
       }
       if (body.mentionIds.some((a) => !c.memberIds.includes(a)))
       if (body.mentionIds.some((a) => !c.memberIds.includes(a)))
         throw new HttpError(400, '只能 @ 群内成员', 'invalid_mentions')
         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(),
       const now = Date.now(),
         runId = randomUUID(),
         runId = randomUUID(),
         agents = c.memberIds.map((id) => this.store.require<Agent>(owner, 'agent', id))
         agents = c.memberIds.map((id) => this.store.require<Agent>(owner, 'agent', id))
@@ -122,7 +126,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           name: f.name,
           name: f.name,
           mimeType: f.mimeType,
           mimeType: f.mimeType,
           size: f.size,
           size: f.size,
-          sender: 'user',
+          sender: f.sender,
           createdAt: now,
           createdAt: now,
         })),
         })),
         tools: [],
         tools: [],
@@ -131,7 +135,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       this.store.saveMessage(owner, message)
       this.store.saveMessage(owner, message)
       for (const file of records) {
       for (const file of records) {
         const existing = this.store.get<Record<string, unknown>>(owner, 'file', file.id)
         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, {
           this.store.put(owner, 'file', file.id, {
             ...existing,
             ...existing,
             conversationId,
             conversationId,
@@ -170,10 +174,20 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     this.nodes.requireSource(owner, agent)
     this.nodes.requireSource(owner, agent)
     if (agent.remoteAgentId) agent = await this.nodes.refreshRemoteAgent(owner,agent)
     if (agent.remoteAgentId) agent = await this.nodes.refreshRemoteAgent(owner,agent)
     const key = this.bindingKey(c.id, run.conversationTaskId, agent.id),
     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)
     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(movedRunner) {
       if(recovering)throw new HttpError(409,'执行节点已变化,不能在另一节点重放原执行','runner_target_changed')
       if(recovering)throw new HttpError(409,'执行节点已变化,不能在另一节点重放原执行','runner_target_changed')
       binding=undefined
       binding=undefined
@@ -189,6 +203,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         conversationTaskId: run.conversationTaskId,
         conversationTaskId: run.conversationTaskId,
         seq: 0,
         seq: 0,
         role: 'assistant',
         role: 'assistant',
+        execution:agent.execution??'profile',
         agentId: agent.id,
         agentId: agent.id,
         agentName: agent.name,
         agentName: agent.name,
         content: '',
         content: '',
@@ -207,6 +222,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         this.saveWork(owner, run)
         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
     const resultMessage = message
     let submitted = recovering,
     let submitted = recovering,
       settled = false,
       settled = false,
@@ -234,7 +250,9 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       this.live.delete(key)
       this.live.delete(key)
       try {
       try {
         const current = this.getWork(owner, run.id)
         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.status = current.status === 'interrupted' ? 'interrupted' : submitted ? 'uncertain' : 'failed'
           current.error = error.message.slice(0, 1000)
           current.error = error.message.slice(0, 1000)
           resultMessage.status = current.status
           resultMessage.status = current.status
@@ -422,6 +440,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             profile: agent.profile,
             profile: agent.profile,
             session_id: binding.storedId,
             session_id: binding.storedId,
             omit_messages: true,
             omit_messages: true,
+            ...(recovering&&target.runner?.computer?{recoverOnly:true}:{}),
             close_on_disconnect: false,
             close_on_disconnect: false,
           })
           })
         : await gateway.rpc('session.create', {
         : await gateway.rpc('session.create', {
@@ -445,6 +464,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         throw new Error('Hermes 会话身份不匹配')
         throw new Error('Hermes 会话身份不匹配')
       binding = {
       binding = {
         runnerId: target.runner?.id,
         runnerId: target.runner?.id,
+        execution:agent.execution,
         id: key,
         id: key,
         nodeId: agent.nodeId,
         nodeId: agent.nodeId,
         remoteAgentId: agent.remoteAgentId,
         remoteAgentId: agent.remoteAgentId,
@@ -529,7 +549,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const attachmentRefs: string[] = []
         const attachmentRefs: string[] = []
         for (const file of trigger.attachments) {
         for (const file of trigger.attachments) {
           this.requireAuthorization(owner, run.runId)
           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 bytes = readFileSync(record.path).toString('base64')
           const attached = record.mimeType.startsWith('image/')
           const attached = record.mimeType.startsWith('image/')
             ? await gateway.rpc('image.attach_bytes', {
             ? await gateway.rpc('image.attach_bytes', {
@@ -685,15 +705,18 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       if (['complete', 'failed', 'interrupted'].includes(current.status)) return
       if (['complete', 'failed', 'interrupted'].includes(current.status)) return
       current.status = 'interrupted'; current.error = '已停止'
       current.status = 'interrupted'; current.error = '已停止'
       this.saveWork(owner, current)
       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('已停止'))
       live.done(new Error('已停止'))
     } else {
     } else {
       const binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
       const binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
       if (work.submitted) {
       if (work.submitted) {
         if (!binding || binding.taskId !== work.id) throw new Error('无法确认待停止成员的会话身份')
         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 {
         try {
           await gateway.connect()
           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 })
           if (opened.running) await gateway.rpc('session.interrupt', { session_id: opened.session_id })
         } finally { gateway.close() }
         } 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。'
 export const HOST_FALLBACK = '我还不能确定你希望我处理什么,请补充具体目标、范围,或明确需要我协调的 Agent。'
 const terminal = (status: string) => ['complete', 'failed', 'interrupted'].includes(status)
 const terminal = (status: string) => ['complete', 'failed', 'interrupted'].includes(status)
 export interface Work {
 export interface Work {
+  resourceWait?:boolean
   id: string
   id: string
   runId: string
   runId: string
   conversationId: 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
         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)
         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 === '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 (c.mode === 'host' || c.kind === 'direct') {
           if (inTask.some(t => c.kind === 'direct'
           if (inTask.some(t => c.kind === 'direct'
             || (!root.assignmentId && (t.runId !== work.runId || t.batchId !== work.batchId))
             || (!root.assignmentId && (t.runId !== work.runId || t.batchId !== work.batchId))
@@ -251,7 +252,7 @@ export abstract class WorkspaceScheduler {
         this.executing.add(work.id)
         this.executing.add(work.id)
         const recovering = work.status === 'uncertain'
         const recovering = work.status === 'uncertain'
         if (!recovering) {
         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' }
           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 } }) }
           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)
           this.saveWork(owner, work)
@@ -272,7 +273,9 @@ export abstract class WorkspaceScheduler {
     } catch (error) {
     } catch (error) {
       if (this.closing) return
       if (this.closing) return
       const current = this.getWork(owner, work.id)
       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.status = current.submitted ? 'uncertain' : 'failed'
         current.error = error instanceof Error ? error.message : '执行失败'
         current.error = error instanceof Error ? error.message : '执行失败'
         this.saveWork(owner, current)
         this.saveWork(owner, current)
@@ -286,7 +289,7 @@ export abstract class WorkspaceScheduler {
       this.executing.delete(work.id)
       this.executing.delete(work.id)
       if (!this.closing) {
       if (!this.closing) {
         const current = this.getWork(owner, work.id)
         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
           const attempt = this.attempts.get(work.id) ?? 0
           this.attempts.set(work.id, attempt + 1)
           this.attempts.set(work.id, attempt + 1)
           this.recoverAfter.set(work.id, Date.now() + Math.min(30_000, 500 * 2 ** Math.min(attempt, 6)))
           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,
     name,
     avatar: avatar.default(''),
     avatar: avatar.default(''),
     instructions: z.string().max(24_000).default(''),
     instructions: z.string().max(24_000).default(''),
+    execution:z.enum(['profile','computer']).default('profile'),
     canManageTeam: z.boolean().default(false),
     canManageTeam: z.boolean().default(false),
     nodeId: z.string().default('local'),
     nodeId: z.string().default('local'),
     profile: z.string().min(1).max(256),
     profile: z.string().min(1).max(256),
@@ -45,6 +46,7 @@ export const agentPatch = z
     name: name.optional(),
     name: name.optional(),
     avatar: avatar.optional(),
     avatar: avatar.optional(),
     instructions: z.string().max(24_000).optional(),
     instructions: z.string().max(24_000).optional(),
+    execution:z.enum(['profile','computer']).optional(),
     canManageTeam: z.boolean().optional(),
     canManageTeam: z.boolean().optional(),
     archived: z.boolean().optional(),
     archived: z.boolean().optional(),
   })
   })
@@ -328,6 +330,7 @@ export class WorkspaceStore {
     if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
     if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
     return this.atomic(() => {
     return this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
       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'))
       if (agent.remoteAgentId && Object.keys(patch).some(key => key !== 'archived'))
         throw new HttpError(409, '引用 Agent 的配置由远端管理', 'remote_agent_read_only')
         throw new HttpError(409, '引用 Agent 的配置由远端管理', 'remote_agent_read_only')
       if (
       if (

+ 11 - 3
src/server/workspaceTeamTools.ts

@@ -11,7 +11,7 @@ import { assignmentInput, assignmentReview, assignmentUpdate, assignmentCancel,
 
 
 const uuid = z.string().uuid()
 const uuid = z.string().uuid()
 const empty = z.object({}).strict()
 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({
 const createTeam = z.object({
   requestId: uuid,
   requestId: uuid,
   name: z.string().trim().min(1).max(100),
   name: z.string().trim().min(1).max(100),
@@ -56,11 +56,16 @@ export const TEAM_TOOL_RULES = '你已获准在 Bot 模式组建团队。使用
 export class WorkspaceTeamTools {
 export class WorkspaceTeamTools {
   constructor(readonly store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly runtime: WorkspaceRuntime) {}
   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)
     this.nodes.requireSource(owner, agent)
     if ((agent.nodeId !== 'local' && !this.nodes.runnerTarget?.(owner,agent.nodeId)) || agent.remoteAgentId)
     if ((agent.nodeId !== 'local' && !this.nodes.runnerTarget?.(owner,agent.nodeId)) || agent.remoteAgentId)
       throw new HttpError(409, '组队发起者需要使用与 Web 服务同机的基础 Agent;远程 Agent 可作为团队成员。', 'team_tools_local_required')
       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)
     this.nodes.requireSource(owner, agent)
   }
   }
 
 
@@ -125,12 +130,15 @@ export class WorkspaceTeamTools {
     }
     }
     if (toolId === 'workspace_create_agent') {
     if (toolId === 'workspace_create_agent') {
       const { requestId, ...body } = parse(createAgent, input)
       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)
       this.nodes.requireSource(owner, body)
       const sources = await this.nodes.sources(owner)
       const sources = await this.nodes.sources(owner)
       this.assertTurn(owner, workId)
       this.assertTurn(owner, workId)
       this.nodes.requireSource(owner, body)
       this.nodes.requireSource(owner, body)
       if (!sources.sources.some(s => s.nodeId === body.nodeId && s.profile === body.profile))
       if (!sources.sources.some(s => s.nodeId === body.nodeId && s.profile === body.profile))
         throw new HttpError(409, '基础 Agent 当前不可用', 'source_unavailable')
         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 }, () => {
       return this.store.command(owner, requestId, { agentId: agent.id, toolId, body }, () => {
         if (this.store.list<Agent>(owner, 'agent').filter(a => !a.archived).length >= 100)
         if (this.store.list<Agent>(owner, 'agent').filter(a => !a.archived).length >= 100)
           throw new HttpError(409, '自动组队最多保留 100 个有效 Agent,请先复用或归档已有成员。', 'team_agent_limit')
           throw new HttpError(409, '自动组队最多保留 100 个有效 Agent,请先复用或归档已有成员。', 'team_agent_limit')

+ 1 - 0
src/shared/runner.ts

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

+ 2 - 0
src/shared/workspace.ts

@@ -5,6 +5,7 @@ export interface WorkspaceAgent {
   avatar: string
   avatar: string
   instructions: string
   instructions: string
   /** Explicit account-owner grant; absent on older records means disabled. */
   /** Explicit account-owner grant; absent on older records means disabled. */
+  execution?: 'profile' | 'computer'
   canManageTeam?: boolean
   canManageTeam?: boolean
   teamAuthorizationVersion?: number
   teamAuthorizationVersion?: number
   createdByAgentId?: string
   createdByAgentId?: string
@@ -79,6 +80,7 @@ export interface WorkspaceFile {
   sender: 'user' | 'agent'
   sender: 'user' | 'agent'
 }
 }
 export interface WorkspaceMessage {
 export interface WorkspaceMessage {
+  execution?:'profile'|'computer'
   id: string
   id: string
   conversationId: string
   conversationId: string
   /** User-visible task scope. `taskId` below remains the scheduler turn ID. */
   /** 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:'执行节点设置'})
   const panel=dialog.getByRole('region',{name:'执行节点设置'})
   await panel.getByRole('textbox',{name:'节点名称',exact:true}).fill('隔离测试 Mac')
   await panel.getByRole('textbox',{name:'节点名称',exact:true}).fill('隔离测试 Mac')
   await panel.getByRole('textbox',{name:'允许的 Profile'}).fill('default')
   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 panel.scrollIntoViewIfNeeded()
   await page.screenshot({path:testInfo.outputPath('runner-registration.png')})
   await page.screenshot({path:testInfo.outputPath('runner-registration.png')})
   await panel.getByRole('button',{name:'注册节点',exact:true}).click()
   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[]=[]
   const stream=await download.createReadStream();const chunks:Buffer[]=[]
   for await(const chunk of stream!)chunks.push(Buffer.from(chunk))
   for await(const chunk of stream!)chunks.push(Buffer.from(chunk))
   const configuration=JSON.parse(Buffer.concat(chunks).toString())
   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)
   expect(configuration.token.length).toBeGreaterThan(32)
   const result=await (await page.request.get('/api/app/admin/runners')).json()
   const result=await (await page.request.get('/api/app/admin/runners')).json()
   expect(JSON.stringify(result)).not.toContain(configuration.token)
   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('已停用')
   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)
   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'})
   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)
   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)
     expect(output).not.toContain(config.token)
   }finally{if(child.exitCode===null&&child.signalCode===null){child.kill('SIGTERM');await exited}}
   }finally{if(child.exitCode===null&&child.signalCode===null){child.kill('SIGTERM');await exited}}
 },15000)
 },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 { randomUUID } from 'node:crypto'
 import { WebSocketServer, type WebSocket } from 'ws'
 import { WebSocketServer, type WebSocket } from 'ws'
 import { defaultAgentIdentity, encodeAgentAvatar, decodeAgentAvatar } from '../../src/shared/agentIdentity'
 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 { WorkspaceStore } from '../../src/server/workspaceStore'
 import { WorkspaceRuntime, mentionedAgents } from '../../src/server/workspaceRuntime'
 import { WorkspaceRuntime, mentionedAgents } from '../../src/server/workspaceRuntime'
 import { WorkspaceNodes } from '../../src/server/workspaceGateway'
 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)
   store.saveRun(owner,run)
   expect(store.require<WorkspaceConversation>(owner,'conversation',c.id).avatarSignals).toEqual({})
   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() }
     } 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('请先停止当前任务')
+})