Bläddra i källkod

新增 Hermes 执行节点与受控团队工具传输

LSamien 1 månad sedan
förälder
incheckning
130917f42a

+ 1 - 0
.gitignore

@@ -24,3 +24,4 @@ hermes-plugins/**/__pycache__/
 hermes-plugins/**/*.py[cod]
 hermes-plugins/**/.pytest_cache/
 hermes-plugins/**/data/
+.runner-build/

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

@@ -62,3 +62,5 @@
 - 任务协调阶段:类型检查、666 项全量测试及真实 Hermes 隔离协议验收通过。真实协议验证包含持久成员、组队、具体成员分派、成员不继承组队工具、管理员验收、目标完成、原会话结果及双管理员并发工具隔离。
 - Web 验证任务状态、结果跳转和独立文字/附件草稿;iOS 验证任务进度、停止和结构化任务跳转;Android API 31 模拟器验证对应工作流。测试日志在本轮工具记录中,后续完整交付仍需重新核对最终代码。
 - 下一阶段必须继续执行节点、受控 Worker、VM、临时助手、资源租约、文件/产物搬运、后台常驻、更新回退等未完成项。当前 App 安装包是桌面基础快照,不是最终完整平台交付。
+- Runner 阶段:新增独立执行节点、管理员注册/配置下载/停用入口,以及打包 CLI。最终类型检查和 680 项全量测试通过(1 项真实协议测试按需跳过);独立打包 Runner 的 13 项协议/CLI 测试通过;浏览器注册流程通过并核对桌面/窄屏截图。
+- 单独启用 `HERMES_TEAM_RUNNER=1` 后,真实 Hermes 隔离协议验收通过,包含原生工具创建成员、组队、结构化分派、验收、完成、结果回传和双管理员工具隔离。执行细节和当前限制见 `docs/runner.md`。这是传输层验收,尚非 VM 或真实模型自主决策验收。

+ 59 - 0
docs/runner.md

@@ -0,0 +1,59 @@
+# Hermes 执行节点
+
+Runner 在运行 Hermes 的电脑上主动连接夭夭服务。用于 Web 位于 Docker、另一台电脑或远程服务器时,将 Bot 会话和本轮组队工具交给 Hermes 所在电脑执行。Runner 源码、构建入口和管理页面均位于 hermes-yaoyao。
+
+这是执行传输层。当前接入的 Hermes 仍使用它原有的工具和权限,Runner 本身不提供 VM 隔离。隔离电脑、受控 Worker、共享桌面和资源租约属于后续实现范围。
+
+## 启用
+
+1. 管理员打开设置 → Hermes 连接 → 执行节点。
+2. 选择接管的 Bot 来源、允许的 Profile、能从执行电脑访问的夭夭地址,以及 Runner 本机 Hermes 的回环地址。
+3. 注册后下载 `runner.json`。节点凭据只在注册响应中返回;列表和刷新不会返回凭据。
+4. 将配置放到执行电脑,执行 `chmod 600 /完整路径/runner.json`。
+5. 在项目目录执行 `npm run build`,然后执行 `npm run runner -- --config /完整路径/runner.json`。
+
+也可执行 `npm run runner:build`,将 `.runner-build/runner.mjs` 复制到执行电脑,以 Node.js 24 或更高版本运行:
+
+```sh
+node runner.mjs --config /完整路径/runner.json
+```
+
+macOS App 的打包构建也包含该入口;当前 App 仍自动托管本地 Web 与缺失的 Hermes 服务。Runner 的原生配置导入与自动托管入口尚待接入,不能把“已打包入口”视作“已自动运行 Runner”。
+
+配置文件至少包括以下字段。`token` 由注册操作生成,不需要填写模型密钥。
+
+```json
+{
+  "protocol": 1,
+  "serverURL": "https://yaoyao.example.com",
+  "runnerId": "注册响应中的节点 UUID",
+  "token": "注册后下载的节点凭据",
+  "hermesURL": "http://127.0.0.1:9119",
+  "allowedProfiles": ["default"],
+  "artifactRoots": []
+}
+```
+
+若 Hermes 启用了账号鉴权,可在本机私有配置添加 `hermesCredentials` 对象,包含 `username` 和 `password`;它们留在执行电脑,控制面不接收 Hermes 密码。回环授权则由现有 UpstreamServiceSession 自动协商。远程明文 HTTP 必须明确设置 `allowInsecureLan: true`。
+
+每个来源同时只允许一个启用的 Runner。已有配对 Web 来源应在目的 Web 服务内注册自己的 Runner。停用会立即关闭控制面的工具授权,执行电脑在连接恢复或轮询时收到拒绝。配置丢失时停用并重新注册;旧配置不能恢复已撤销的节点。
+
+## 协议与执行边界
+
+- 浏览器管理接口使用原有管理员登录和 CSRF。机器接口使用独立 Bearer 凭据、Runner 实例 UUID、协议版本与连接代次;拒绝带 Origin 的请求。
+- Runner 以 HTTP 长轮询领取有期限的命令,通过独立请求提交结果、会话事件和工具调用。慢操作不会阻塞后续关闭命令。
+- 控制面重启、Runner 更换、断线重连都会更换连接代次;旧结果、旧实例和旧工具授权不能接管新执行。
+- 命令执行前再次向控制面核对授权和期限。账号权限变化会使旧轮次失效。一个会话被撤权只关闭自己的通道和工具。
+- 本机 SQLite 保存命令指纹和执行回执。重复命令复用回执;未完成的历史命令视为结果不确定,禁止自动重放。大响应一小时后压缩为禁止重放的记录。
+- 组队工具只在 Hermes 本机建立回环租约,远端不开放原有回环接口。模型获得的是当轮不透明工具 ID;账号、来源、会话和调用身份由服务端绑定。
+- Gateway RPC 使用固定允许列表;会话操作必须属于自己的通道。HTTP 仅允许必要的只读查询。文件导出要求真实路径位于 `artifactRoots`,留空拒绝导出。
+- 单个只读结果目前最多 8 MiB;大型产物的分块传输仍待实现。待传命令有数量和总字节限制。
+- 断线后的在途操作可能结果不确定。系统沿用原会话核对,不在另一个节点重放原任务。已完成会话切换节点后带入近期文字记录;历史附件路径需要重新核实。
+
+## 验收
+
+`tests/server/runner.test.ts` 使用独立 HTTP/WebSocket 服务验证实际传输、权限交集、调用去重、未完成回执、连接代次、断线重连、取消竞态和会话隔离。
+
+`tests/e2e/team-tools.spec.ts` 覆盖注册、配置下载、列表不返回凭据、停用以及窄屏布局。
+
+真实 Hermes 协议验收沿用 `docs/bot-agent-teams.md` 的隔离 fixture。添加 `HERMES_TEAM_RUNNER=1` 后,原生工具创建成员、组队、分派、复核、完成目标、回传原会话和双管理员并发都经过 Runner。模型决策由确定脚本替代,不代表真实模型自主决策质量,也不代表 VM 验收。

+ 2 - 0
electron-builder.yml

@@ -11,6 +11,8 @@ extraResources:
     filter:
       - server.mjs
       - server.mjs.map
+      - runner.mjs
+      - runner.mjs.map
       - release.json
       - ui/**/*
 asar: true

+ 4 - 1
package.json

@@ -29,7 +29,10 @@
     "desktop:build": "npm run build && node scripts/build-desktop.mjs",
     "desktop:dev": "npm run desktop:build && electron .",
     "desktop:pack": "npm run desktop:build && electron-builder --mac --arm64 --publish never",
-    "desktop:test": "node --test desktop/*.test.mjs"
+    "desktop:test": "node --test desktop/*.test.mjs",
+    "runner": "node dist-server/runner/index.js",
+    "runner:dev": "tsx src/runner/index.ts",
+    "runner:build": "node scripts/build-runner.mjs"
   },
   "dependencies": {
     "@koa/bodyparser": "^5.0.0",

+ 2 - 0
scripts/build-desktop.mjs

@@ -1,4 +1,5 @@
 import { build } from 'esbuild'
+import { buildRunner } from './build-runner.mjs'
 import { cp, mkdir, rm, readdir, readFile, writeFile } from 'node:fs/promises'
 import { resolve } from 'node:path'
 
@@ -10,6 +11,7 @@ await build({ entryPoints: [resolve(root, 'src/server/index.ts')], outfile: reso
   banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" },
   define: { 'process.env.NODE_ENV': '"production"' }, sourcemap: true,
 })
+await buildRunner(out)
 await cp(resolve(root, 'dist'), resolve(out, 'ui'), { recursive: true })
 await cp(resolve(root, 'release.json'), resolve(out, 'release.json'))
 await cp(resolve(root, 'public/icons/icon-512.png'), resolve(root, 'desktop/icon.png'))

+ 18 - 0
scripts/build-runner.mjs

@@ -0,0 +1,18 @@
+import { build } from 'esbuild'
+import { mkdir } from 'node:fs/promises'
+import { resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+export async function buildRunner(directory = resolve(import.meta.dirname, '../.runner-build')) {
+  await mkdir(directory, { recursive: true })
+  await build({
+    entryPoints: [resolve(import.meta.dirname, '../src/runner/index.ts')],
+    outfile: resolve(directory, 'runner.mjs'), bundle: true, platform: 'node',
+    format: 'esm', target: 'node24', sourcemap: true,
+    banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" },
+  })
+}
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  await buildRunner()
+  console.log('执行节点已打包到 .runner-build/runner.mjs(需要 Node.js 24 或更高版本)')
+}

+ 101 - 0
src/client/components/app/RunnerSettingsPanel.vue

@@ -0,0 +1,101 @@
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import { apiRequest } from '@/api/client'
+import type { RunnerConfiguration, RunnerRecord } from '@shared/runner'
+
+type Runner = Omit<RunnerRecord, 'tokenHash' | 'sourceOwner'> & {online: boolean}
+const runners = ref<Runner[]>([]), sources = ref<Array<{id:string;name:string}>>([])
+const busy = ref(false), error = ref(''), notice = ref('')
+const name = ref(''), source = ref('local'), profiles = ref('default')
+const serverURL = ref(window.location.origin), hermesURL = ref('http://127.0.0.1:9119')
+const artifactRoots = ref(''), insecure = ref(false), configuration = ref<RunnerConfiguration>()
+
+async function refresh() {
+  const data = await apiRequest<{runners:Runner[];sources:Array<{id:string;name:string}>}>('/api/app/admin/runners')
+  runners.value = data.runners; sources.value = data.sources
+}
+async function action(work:()=>Promise<void>) {
+  if(busy.value) return
+  error.value = ''; notice.value = ''; busy.value = true
+  try { await work() } catch(cause) { error.value = cause instanceof Error ? cause.message : '执行节点操作失败' }
+  finally { busy.value = false }
+}
+async function register() {
+  await action(async()=>{
+    const web = new URL(serverURL.value), local = new URL(hermesURL.value)
+    if(!['http:','https:'].includes(web.protocol)||web.username||web.password||web.search||web.hash||web.pathname!=='/')throw new Error('请填写不带路径的夭夭服务地址')
+    if(web.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(web.hostname)&&!insecure.value)throw new Error('远程节点请使用 HTTPS,或勾选可信局域网 HTTP')
+    if(!['127.0.0.1','[::1]'].includes(local.hostname)||!['http:','https:'].includes(local.protocol)||local.username||local.password)throw new Error('Hermes 地址必须指向 Runner 所在电脑的回环地址')
+    const allowedProfiles = [...new Set(profiles.value.split(/[\s,,]+/).filter(Boolean))]
+    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}:{})}
+    notice.value = '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。'
+    await refresh()
+  })
+}
+function download() {
+  if(!configuration.value) return
+  const url = URL.createObjectURL(new Blob([JSON.stringify(configuration.value,null,2)+'\n'],{type:'application/json'}))
+  const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'runner.json'; anchor.click()
+  setTimeout(()=>URL.revokeObjectURL(url),1000)
+}
+function disable(runner:Runner) {
+  void action(async()=>{
+    await apiRequest(`/api/app/admin/runners/${runner.id}`,{method:'DELETE',body:{}})
+    if(configuration.value?.runnerId===runner.id)configuration.value=undefined
+    await refresh(); notice.value = `已停用 ${runner.name}`
+  })
+}
+onMounted(()=>{void action(refresh)})
+</script>
+
+<template>
+  <section class="runner-panel" aria-label="执行节点设置">
+    <p>在运行 Hermes 的电脑上启动 Runner,它会主动连接夭夭,接收 Bot 任务并提供本轮团队工具。注册后,该来源的 Bot 任务会交给 Runner;节点离线时无法启动新任务,恢复连接后可重试。</p>
+    <div class="runner-toolbar"><strong>已注册节点</strong><button type="button" :disabled="busy" @click="action(refresh)">刷新状态</button></div>
+    <ul v-if="runners.length" class="runner-list">
+      <li v-for="runner in runners" :key="runner.id">
+        <div><strong>{{ runner.name }}</strong><span>{{ !runner.enabled ? '已停用' : runner.online ? '已连接' : '待连接' }}</span><small>{{ runner.allowedProfiles.join('、') }}</small></div>
+        <button v-if="runner.enabled" type="button" :disabled="busy" :aria-label="`停用 ${runner.name}`" @click="disable(runner)">停用</button>
+      </li>
+    </ul>
+    <p v-else>尚未注册执行节点。</p>
+    <form v-if="!configuration" @submit.prevent="register">
+      <label>节点名称<input v-model="name" required maxlength="100" autocomplete="off" placeholder="我的 Mac" :disabled="busy"></label>
+      <label>接管的 Bot 来源<select v-model="source" :disabled="busy"><option v-for="item in sources" :key="item.id" :value="item.id">{{ item.name }}</option></select></label>
+      <label>允许的 Profile<input v-model="profiles" required placeholder="default, writer" :disabled="busy"><small>填写 Hermes 的 Profile 名称,以逗号分隔;账号权限仍然生效。</small></label>
+      <label>夭夭服务地址<input v-model="serverURL" type="url" required :disabled="busy"><small>必须能从 Runner 所在电脑访问。</small></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 class="runner-check"><input v-model="insecure" type="checkbox" :disabled="busy">使用可信局域网 HTTP</label>
+      <button class="solid-button" :disabled="busy || !name.trim() || !profiles.trim()">注册节点</button>
+    </form>
+    <div v-else class="runner-setup">
+      <button class="solid-button" type="button" @click="download">下载 Runner 配置</button>
+      <p>配置包含本节点的连接凭据,仅本次可下载。将文件放在 Hermes 所在电脑,设置为仅自己可读:</p>
+      <code>chmod 600 runner.json<br>npm run runner -- --config /完整路径/runner.json</code>
+      <small>在 hermes-yaoyao 项目中先运行 npm run build。配置丢失后可停用节点并重新注册。</small>
+    </div>
+    <p v-if="error" class="runner-error" role="alert">{{ error }}</p>
+    <p v-if="notice" role="status">{{ notice }}</p>
+  </section>
+</template>
+
+<style scoped>
+.runner-panel { display: grid; gap: 14px; min-width: 0; font-size: 13px; }
+.runner-panel p { margin: 0; line-height: 1.6; color: var(--text-secondary); }
+.runner-toolbar,.runner-list li { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.runner-panel button { min-height: 36px; padding: 7px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-raised); color: var(--text-primary); cursor: pointer; }
+.runner-panel button:disabled { opacity: .5; cursor: default; }
+.runner-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
+.runner-list li { padding: 12px; border: 1px solid var(--line); border-radius: 10px; }
+.runner-list li div { display: grid; gap: 5px; overflow-wrap: anywhere; }
+.runner-list span,.runner-panel small { color: var(--text-secondary); font-size: 12px; line-height: 1.5; }
+.runner-panel form,.runner-panel label,.runner-setup { display: grid; gap: 8px; min-width: 0; }
+.runner-panel form { gap: 14px; }
+.runner-panel input:not([type=checkbox]),.runner-panel select,.runner-panel textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-raised); color: var(--text-primary); font: inherit; }
+.runner-panel .runner-check { display: flex; align-items: center; }
+.runner-panel code { overflow-wrap: anywhere; line-height: 1.7; }
+.runner-panel .runner-error { color: var(--danger); }
+.runner-panel :is(input,select,textarea,button):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+</style>

+ 8 - 0
src/client/components/app/SystemManagementPanel.vue

@@ -2,6 +2,7 @@
 import { computed, ref, watch } from 'vue'
 import type { Profile } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
+import RunnerSettingsPanel from './RunnerSettingsPanel.vue'
 import {
   createUser,
   deleteUser,
@@ -40,6 +41,7 @@ const props = withDefaults(defineProps<{
 
 const emit = defineEmits<{ 'dirty-change': [dirty: boolean] }>()
 
+const runnerSettingsOpen = ref(false)
 const assignedProfiles = ref<string[]>([])
 const userAssignments = ref<Record<string, string[]>>({})
 const users = ref<ManagedUser[]>([])
@@ -450,6 +452,10 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
         <label><span>9119 密码</span><input v-model="upstreamPassword" name="upstream-password" type="password" autocomplete="new-password" :disabled="busy" /></label>
         <button class="solid-button" :disabled="busy || !upstreamUsername.trim() || !upstreamPassword">验证并保存</button>
       </form>
+      <details class="runner-settings" @toggle="runnerSettingsOpen = ($event.target as HTMLDetailsElement).open">
+        <summary>执行节点</summary>
+        <RunnerSettingsPanel v-if="runnerSettingsOpen" />
+      </details>
       <div class="block network-access-block">
         <h3>外网访问地址</h3>
         <p class="network-description">允许通过指定域名或公网 IP 访问 15300。每行填写一个地址,不要包含 <code>http://</code>、端口或路径。</p>
@@ -536,6 +542,8 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
 </template>
 
 <style scoped>
+.runner-settings { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--line); }
+.runner-settings summary { cursor: pointer; font-weight: 600; padding: 8px 0; }
 .system-management-panel { display: grid; }
 .block { min-width: 0; margin: 0; padding: 0; }
 .block + .block { margin-top: 26px; padding-top: 24px; border-top: 1px solid var(--line); }

+ 216 - 0
src/runner/agent.ts

@@ -0,0 +1,216 @@
+import { randomUUID, createHash } from 'node:crypto'
+import { DatabaseSync } from 'node:sqlite'
+import { realpath } from 'node:fs/promises'
+import { resolve, sep, join } from 'node:path'
+import { HttpError } from '../server/errors.js'
+import { UpstreamClient } from '../server/upstream.js'
+import { UpstreamServiceSession } from '../server/localAuth.js'
+import { WorkspaceGateway, type GatewayTarget, type GatewayFrame } from '../server/workspaceGateway.js'
+import { createWorkspaceToolLease, type WorkspaceToolLease } from '../server/workspaceToolLease.js'
+import type { RunnerCommand, RunnerConfiguration } from '../shared/runner.js'
+import { LoopbackTransport, isLocalAuthorizationTarget } from '../server/loopbackAuthorization.js'
+
+const commands=new Set(['session.create','session.resume','session.close','session.usage','session.interrupt','session.cwd.set','prompt.submit','session.steer','image.attach_bytes','file.attach','approval.respond','clarify.respond'])
+interface Connection {gateway:WorkspaceGateway;sessions:Map<string,string>;running:Set<string>;events:Promise<void>}
+interface Lease {connectionId:string;profile:string;lease:WorkspaceToolLease;controller:AbortController;session:{runtimeId:string;storedId:string}}
+
+export class RunnerAgent {
+  readonly instance=randomUUID()
+  private active=true
+  private connected=false
+  private disconnectRevision=0
+  private disconnecting?:Promise<void>
+  private connections=new Map<string,Connection>()
+  private leases=new Map<string,Lease>()
+  private closedConnections=new Map<string,number>()
+  private closedLeases=new Map<string,number>()
+  private inflight=new Map<string,Promise<unknown>>()
+  private db:DatabaseSync
+  private target:GatewayTarget
+  private controlTransport=new LoopbackTransport()
+  private serverEpoch?:string
+  private controlAbort=new AbortController()
+  constructor(readonly config:RunnerConfiguration,home:string,readonly fetchImpl:typeof fetch=fetch,readonly onStatus:(message:string)=>void=()=>{}) {
+    const web=new URL(config.serverURL),hermes=new URL(config.hermesURL)
+    if(web.username||web.password||web.search||web.hash||!['http:','https:'].includes(web.protocol))throw new Error('Web 地址无效')
+    if(web.protocol==='http:'&&!['127.0.0.1','localhost','[::1]'].includes(web.hostname)&&!config.allowInsecureLan)throw new Error('远程执行节点必须使用 HTTPS,或明确配置可信局域网 HTTP')
+    if(hermes.username||hermes.password||hermes.search||hermes.hash||!['127.0.0.1','[::1]'].includes(hermes.hostname)||!['http:','https:'].includes(hermes.protocol))throw new Error('Runner 必须连接本机 Hermes')
+    const client=new UpstreamClient(hermes)
+    this.target={url:hermes,client,session:new UpstreamServiceSession(client,()=>config.hermesCredentials)}
+    this.db=new DatabaseSync(join(home,'runner-commands.sqlite3'))
+    this.db.exec('CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,state TEXT NOT NULL,result TEXT,created INTEGER NOT NULL)')
+    this.compactReceipts()
+  }
+  private async api(path:string,body?:unknown):Promise<any> {
+    const url=new URL(`/api/runner/v1/${this.config.runnerId}/${path}`,this.config.serverURL)
+    if(url.hostname==='localhost')url.hostname='127.0.0.1'
+    const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
+      method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(25000)]),
+      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
+      ...(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')
+    const reader=response.body?.getReader();const chunks:Uint8Array[]=[];let bytes=0
+    if(!reader)throw new Error('执行节点响应为空')
+    try{for(;;){const part=await reader.read();if(part.done)break;bytes+=part.value.length;if(bytes>40*1024*1024)throw new Error('执行节点响应超过大小限制');chunks.push(part.value)}}finally{await reader.cancel()}
+    return JSON.parse(Buffer.concat(chunks).toString())
+  }
+  private requireProfile(profile:unknown):string {
+    if(typeof profile!=='string'||!this.config.allowedProfiles.includes(profile))throw new HttpError(403,'Profile 未授权','runner_profile_forbidden')
+    return profile
+  }
+  private async closeConnection(id:string,interrupt=true) {
+    this.closedConnections.set(id,Date.now()+60000)
+    const connection=this.connections.get(id)
+    if(!connection)return
+    this.connections.delete(id)
+    for(const [leaseId,lease] of this.leases)if(lease.connectionId===id){this.closedLeases.set(leaseId,Date.now()+60000);lease.controller.abort();this.leases.delete(leaseId);void lease.lease.dispose()}
+    if(interrupt){let timer:ReturnType<typeof setTimeout>|undefined;try{await Promise.race([Promise.all([...connection.running].map(session_id=>connection.gateway.rpc('session.interrupt',{session_id}).catch(()=>{}))),new Promise(resolve=>{timer=setTimeout(resolve,3000)})])}finally{clearTimeout(timer)}}
+    connection.gateway.close()
+  }
+  private disconnect():Promise<void> {
+    if(this.disconnecting)return this.disconnecting
+    this.disconnectRevision++;this.connected=false
+    const leases=[...this.leases.values()];this.leases.clear()
+    for(const value of leases)value.controller.abort()
+    const pending=Promise.all([
+      ...leases.map(value=>value.lease.dispose()),
+      ...[...this.connections.keys()].map(id=>this.closeConnection(id)),
+    ]).then(()=>{}).finally(()=>{if(this.disconnecting===pending)this.disconnecting=undefined})
+    this.disconnecting=pending;return pending
+  }
+  private async execute(command:RunnerCommand):Promise<any> {
+    const p=command.payload
+    if(!this.active||!this.connected)throw new Error('执行节点已断开')
+    if(command.kind==='http') {
+      const path=String(p.path),search=new URLSearchParams(String(p.search??''))
+      if(p.method!=='GET'||!(/^\/api\/(?:status|profiles|config)$/.test(path)||/^\/api\/sessions\/[^/]+\/messages$/.test(path)||path==='/api/plugins/yaoyao-bot-bridge/capabilities'||path==='/api/files/download'))throw new HttpError(403,'Runner 不支持此 HTTP 操作','runner_http_forbidden')
+      if(!['/api/status','/api/profiles'].includes(path))this.requireProfile(search.get('profile')??'default')
+      if(path==='/api/files/download') {
+        const requested=await realpath(String(search.get('path')??''))
+        const roots=await Promise.all(this.config.artifactRoots.map(root=>realpath(root)))
+        if(!roots.some(root=>requested===root||requested.startsWith(root+sep)))throw new HttpError(403,'文件不在允许的产物目录','runner_artifact_forbidden')
+      }
+      const response=await this.target.session.request(path,{search,maxResponseBytes:8*1024*1024})
+      return {status:response.status,headers:Object.fromEntries(response.headers.entries()),body:response.body.toString('base64')}
+    }
+    const connectionId=String(p.connectionId??'')
+    if(command.kind==='gateway.open') {
+      if(this.closedConnections.has(connectionId)||this.connections.has(connectionId)||!/^[0-9a-f-]{36}$/.test(connectionId)||this.connections.size>=64)throw new Error('执行通道无效或已达上限')
+      const gateway=new WorkspaceGateway(this.target)
+      const connection:Connection={gateway,sessions:new Map(),running:new Set(),events:Promise.resolve()}
+      this.connections.set(connectionId,connection)
+      gateway.onEvent=(frame:GatewayFrame)=>{
+        if(frame.session_id&&!connection.sessions.has(frame.session_id))return
+        if(frame.session_id&&['message.complete','run.completed','run.failed'].includes(frame.type))connection.running.delete(frame.session_id)
+        if(frame.type==='session.info'&&frame.session_id)for(const lease of this.leases.values())if(lease.session.runtimeId===frame.session_id&&typeof frame.payload?.stored_session_id==='string')lease.session.storedId=frame.payload.stored_session_id
+        connection.events=connection.events.then(()=>this.api('event',{connectionId,frame})).then(()=>{},error=>{if(error instanceof HttpError&&error.status===410)void this.closeConnection(connectionId);else void this.disconnect()})
+      }
+      gateway.onDisconnect=()=>{void this.api('event',{connectionId,closed:true}).catch(()=>{})}
+      try {await gateway.connect();if(!this.active||!this.connected||this.connections.get(connectionId)!==connection)throw new Error('执行节点已断开');return {ok:true}}catch(error){this.connections.delete(connectionId);gateway.close();throw error}
+    }
+    if(command.kind==='gateway.close'){await this.closeConnection(connectionId);return {ok:true}}
+    if(command.kind==='gateway.rpc') {
+      const connection=this.connections.get(connectionId),method=String(p.method),params=p.params as Record<string,unknown>
+      if(!connection||!commands.has(method)||!params||typeof params!=='object')throw new HttpError(403,'执行通道或命令无效','runner_command_forbidden')
+      if(method==='session.create'||method==='session.resume')this.requireProfile(params.profile)
+      else if(!connection.sessions.has(String(params.session_id)))throw new HttpError(403,'会话不属于这个通道','runner_session_forbidden')
+      if(method==='prompt.submit')connection.running.add(String(params.session_id))
+      const result=await connection.gateway.rpc(method,params)
+      if(method==='session.create'||method==='session.resume'){
+        if(this.connections.get(connectionId)!==connection)throw new HttpError(409,'执行通道已关闭','runner_command_uncertain')
+        connection.sessions.set(String(result.session_id),String(params.profile))
+      }
+      if(method==='session.interrupt')connection.running.delete(String(params.session_id))
+      return result
+    }
+    const leaseId=String(p.leaseId??'')
+    if(command.kind==='lease.create') {
+      this.requireProfile(p.profile)
+      if(this.closedLeases.has(leaseId)||this.leases.has(leaseId)||!/^[0-9a-f-]{36}$/.test(leaseId)||this.leases.size>=64)throw new Error('工具授权无效或已达上限')
+      const session=p.session as {runtimeId:string;storedId:string}
+      const source=[...this.connections].find(([,c])=>c.sessions.get(session?.runtimeId)===p.profile)
+      if(!source)throw new HttpError(403,'工具会话不属于此节点','runner_session_forbidden')
+      const controller=new AbortController()
+      const lease=await createWorkspaceToolLease({target:this.target,profile:String(p.profile),workId:String(p.workId),session:()=>session,signal:controller.signal,
+        assertActive:()=>{if(!this.connected||controller.signal.aborted)throw new Error('工具授权已失效')},
+        catalog:()=>p.catalog as any[],call:async(toolId,args,callId)=>(await this.api('tool',{leaseId,toolId,arguments:args,callId:callId??randomUUID()})).result,
+        onFailure:()=>{controller.abort()},
+      })
+      if(!this.active||!this.connected||this.closedLeases.has(leaseId)||!this.connections.has(source[0])){controller.abort();await lease.dispose();throw new Error('工具授权已停止')}
+      this.leases.set(leaseId,{lease,controller,session,connectionId:source[0],profile:String(p.profile)});return {ok:true}
+    }
+    if(command.kind==='lease.bind') {
+      const lease=this.leases.get(leaseId)
+      if(!lease)throw new HttpError(410,'工具授权已关闭','runner_lease_expired')
+      const update=p.session as {runtimeId?:string;storedId?:string}|undefined
+      if(update?.runtimeId!==lease.session.runtimeId||!this.connections.get(lease.connectionId)?.sessions.has(update.runtimeId))throw new HttpError(403,'工具不能改绑其他会话','runner_session_forbidden')
+      if(typeof update.storedId!=='string')throw new Error('存储会话编号无效')
+      lease.session.storedId=update.storedId;await lease.lease.bind();return {ok:true}
+    }
+    if(command.kind==='lease.close') {
+      this.closedLeases.set(leaseId,Date.now()+60000)
+      const lease=this.leases.get(leaseId);this.leases.delete(leaseId)
+      if(lease){lease.controller.abort();await lease.lease.dispose()}return {ok:true}
+    }
+    throw new HttpError(400,'未知执行命令','runner_command_unknown')
+  }
+  private compactReceipts(){
+    // Keep fingerprints as replay tombstones; large HTTP/file replies expire.
+    this.db.prepare("UPDATE commands SET state='retired',result=NULL WHERE state='complete' AND created<?").run(Date.now()-3600000)
+  }
+  private handled=0
+  async handle(command:RunnerCommand):Promise<unknown> {
+    if(++this.handled%256===0)this.compactReceipts()
+    for(const map of [this.closedConnections,this.closedLeases])for(const [id,expires] of map)if(expires<Date.now())map.delete(id)
+    const existing=this.inflight.get(command.id)
+    const fingerprint=createHash('sha256').update(JSON.stringify(command)).digest('hex')
+    const previous=this.db.prepare('SELECT * FROM commands WHERE id=?').get(command.id)
+    if(previous){
+      if(previous.fingerprint!==fingerprint)throw new HttpError(409,'命令编号已用于其他内容','idempotency_conflict')
+      if(existing)return existing
+      if(previous.state==='complete')return JSON.parse(String(previous.result))
+      throw new HttpError(409,'原命令结果不确定,禁止重复执行','runner_command_uncertain')
+    }
+    this.db.prepare("INSERT INTO commands VALUES(?,?,'started',NULL,?)").run(command.id,fingerprint,Date.now())
+    const pending=Promise.resolve().then(async()=>{
+      if(command.expiresAt<=Date.now()||!(await this.api('admit',{id:command.id})).allowed||command.expiresAt<=Date.now())throw new HttpError(403,'命令授权已经失效,未执行','runner_command_not_admitted')
+      return this.execute(command)
+    }).then(result=>({result}),error=>({error:{message:error instanceof HttpError?error.message:'执行节点操作失败',code:error instanceof HttpError?error.code:'runner_error'}}))
+      .then(reply=>{this.db.prepare("UPDATE commands SET state='complete',result=? WHERE id=?").run(JSON.stringify(reply),command.id);return reply})
+      .finally(()=>this.inflight.delete(command.id))
+    this.inflight.set(command.id,pending);return pending
+  }
+  private lastStatus=''
+  private status(message:string){if(message!==this.lastStatus){this.lastStatus=message;this.onStatus(message)}}
+  async run(signal:AbortSignal):Promise<void> {
+    const deliveries=new Map<string,Promise<void>>()
+    const abort=()=>{this.active=false;this.controlAbort.abort();void this.disconnect()};signal.addEventListener('abort',abort,{once:true})
+    try {
+      while(this.active&&!signal.aborted) {
+        try {
+          const revision=this.disconnectRevision
+          const response=await this.api('poll')
+          await this.disconnecting
+          if(revision!==this.disconnectRevision)continue
+          if(this.serverEpoch&&this.serverEpoch!==response.epoch)await this.disconnect()
+          this.serverEpoch=response.epoch;this.connected=true;this.status('执行节点已连接')
+          for(const command of response.commands as RunnerCommand[]){
+            if(deliveries.has(command.id))continue
+            const delivery=(async()=>{
+            let reply
+            try {reply=await this.handle(command)}catch(error){reply={error:{message:'原命令需要核对',code:error instanceof HttpError?error.code:'runner_error'}}}
+            await this.api('result',{id:command.id,...reply as object})
+            })().catch(()=>{}).finally(()=>deliveries.delete(command.id))
+            deliveries.set(command.id,delivery)
+          }
+        }catch(error){
+          this.status(error instanceof HttpError?error.message:'连接暂时中断,正在重连')
+          await this.disconnect()
+          if(error instanceof HttpError&&error.code==='runner_unauthorized')throw error
+          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('执行节点已停止')}
+  }
+}

+ 16 - 0
src/runner/index.ts

@@ -0,0 +1,16 @@
+import { readFileSync, statSync, mkdirSync } from 'node:fs'
+import { resolve, dirname, isAbsolute } from 'node:path'
+import { z } from 'zod'
+import { RunnerAgent } from './agent.js'
+import { acquireServiceInstance } from '../server/serviceInstance.js'
+
+const index=process.argv.indexOf('--config'),path=index>=0?process.argv[index+1]:undefined
+if(!path)throw new Error('用法:yaoyao-runner --config /path/to/runner.json')
+if(process.platform!=='win32'&&(statSync(path).mode&0o077)!==0)throw new Error('执行节点配置必须为仅当前用户可读(chmod 600)')
+const config=z.object({protocol:z.literal(1),serverURL:z.string().url(),runnerId:z.string().uuid(),token:z.string().min(32),hermesURL:z.string().url().default('http://127.0.0.1:9119'),allowedProfiles:z.array(z.string().trim().min(1).max(256).regex(/^[^/\\\u0000-\u001f]+$/)).min(1).max(256),artifactRoots:z.array(z.string().refine(isAbsolute,'产物目录必须为绝对路径')).max(64).default([]),allowInsecureLan:z.boolean().optional(),hermesCredentials:z.object({username:z.string(),password:z.string()}).optional()}).strict().parse(JSON.parse(readFileSync(path,'utf8')))
+const home=resolve(dirname(path),'state',config.runnerId);mkdirSync(home,{recursive:true,mode:0o700})
+const instance=acquireServiceInstance(home,'1.0.0'),controller=new AbortController()
+process.once('SIGTERM',()=>controller.abort());process.once('SIGINT',()=>controller.abort())
+process.once('exit',()=>instance.release())
+const agent=new RunnerAgent(config,home,fetch,message=>process.stderr.write(`${new Date().toISOString()} ${message}\n`))
+try {await agent.run(controller.signal)}finally{instance.release()}

+ 8 - 0
src/server/app.ts

@@ -49,6 +49,7 @@ import {
   HermesChatNotificationResolver,
 } from './pushEvents.js'
 import { ChatCacheCoordinator, ChatCacheStore } from './chatCache.js'
+import { RunnerHub } from './runnerHub.js'
 
 export interface ApplicationOptions {
   config?: ServerConfig
@@ -69,6 +70,7 @@ export interface ApplicationOptions {
 }
 
 export interface ApplicationRuntime {
+  runners: RunnerHub
   workspace: WorkspaceStore
   workspaceRuntime: WorkspaceRuntime
   realtime: RealtimeAPI
@@ -205,6 +207,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const workspace = new WorkspaceStore(config.home)
   const workspaceNodes = new WorkspaceNodes(workspace, config, { url: config.upstream, client: upstream, session: upstreamSession }, pairings.nodeID)
   workspaceNodes.sourceAllowed = (owner, nodeId, profile) => auth.canUseSource(owner, nodeId, profile)
+  const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
+  workspaceNodes.runnerTarget=(owner,nodeId)=>runners.target(owner,nodeId)
   const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0)
   workspaceRuntime.onTeamCreated = (owner, team) => {
     try { push.setGroupSubscription(owner, team.id, true, team.lastSeq) } catch { /* Optional notifications do not undo a team. */ }
@@ -291,6 +295,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     }
     await next()
   })
+  app.use(runners.middleware())
   app.use(async (ctx, next) => {
     const user = auth.current(ctx)
     if (user && user.role !== 'admin' && (ctx.path.startsWith('/api/') || ctx.path.startsWith('/node/') || ctx.path.startsWith('/ws/') || ctx.path.startsWith('/Users/') || ctx.path.startsWith('/attachments/'))) {
@@ -366,6 +371,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   })
 
   const applicationRouter = workspaceRouter(workspace, workspaceRuntime, workspaceNodes, workspaceAssets, uploads, auth, push)
+  app.use(runners.adminRouter().routes())
   app.use(applicationRouter.routes())
   app.use(async (ctx, next) => {
     if (ctx.path.includes('/plugins/yaoyao') || ctx.path.startsWith('/api/app/groups')) throw new HttpError(410, '请使用新版聊天接口', 'retired_plugin_api')
@@ -422,6 +428,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   })
 
   return {
+    runners,
     workspace,
     workspaceRuntime,
     realtime,
@@ -444,6 +451,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     chatPushJobs,
     chatCache,
     close: () => {
+      runners.close()
       workspaceAssets.close()
       workspaceRuntime.close()
       workspaceNodes.close()

+ 210 - 0
src/server/runnerHub.ts

@@ -0,0 +1,210 @@
+import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
+import Router from '@koa/router'
+import type Koa from 'koa'
+import { z } from 'zod'
+import { HttpError } from './errors.js'
+import { parse, type WorkspaceStore } from './workspaceStore.js'
+import type { LocalAuthStore } from './localAuth.js'
+import type { GatewayFrame, GatewayTarget } from './workspaceGateway.js'
+import type { UpstreamRequestOptions, UpstreamResponse } from './upstream.js'
+import type { LeaseInput, WorkspaceToolLease } from './workspaceToolLease.js'
+import { RUNNER_PROTOCOL, type RunnerCommand, type RunnerRecord } from '../shared/runner.js'
+
+const hash = (value: string) => createHash('sha256').update(value).digest('hex')
+const input = z.object({ name:z.string().trim().min(1).max(100),sourceNodeId:z.string().min(1).max(100).default('local'),
+  allowedProfiles:z.array(z.string().min(1).max(100)).min(1).max(256) }).strict()
+interface Pending { command:RunnerCommand; resolve(value:any):void; reject(error:Error):void; timer:ReturnType<typeof setTimeout>; nextDelivery:number; valid():boolean }
+interface GatewayConnection { runnerId:string; valid():boolean; onEvent(frame:GatewayFrame):void; onDisconnect():void }
+interface Lease { runnerId:string; input:LeaseInput; calls:Map<string,{fingerprint:string;result:Promise<unknown>}> }
+
+/** Outbound-only machine transport. Browser auth and machine auth remain separate. */
+export class RunnerHub {
+  readonly epoch=randomUUID()
+  private online = new Map<string,{instance:string;seen:number;epoch:string;wake?:()=>void}>()
+  private retired = new Map<string,Set<string>>()
+  private pending = new Map<string,Map<string,Pending>>()
+  private connections = new Map<string,GatewayConnection>()
+  private leases = new Map<string,Lease>()
+  private watchdog: ReturnType<typeof setInterval>
+  constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly local:GatewayTarget) {
+    this.watchdog=setInterval(()=>{
+      for(const [id,state] of this.online) if(Date.now()-state.seen>35_000)this.disconnect(id)
+    },5000);this.watchdog.unref()
+  }
+  records():RunnerRecord[] {return this.store.list('_system','runner')}
+  enroll(owner:string,value:unknown) {
+    const body=parse(input,value)
+    body.allowedProfiles=this.auth.validateAssignedProfiles(body.allowedProfiles)
+    if(body.sourceNodeId!=='local'&&this.store.require<import('./workspaceGateway.js').WorkspaceNode>(owner,'node',body.sourceNodeId).transport==='paired-web')throw new HttpError(409,'配对 Web 节点应在它自己的服务中注册 Runner','runner_source_invalid')
+    const sourceOwner=body.sourceNodeId==='local'?'_system':owner
+    if(this.records().some(r=>r.enabled&&r.sourceOwner===sourceOwner&&r.sourceNodeId===body.sourceNodeId))
+      throw new HttpError(409,'该来源已有执行节点,请先停用原节点','runner_source_in_use')
+    const token=randomBytes(32).toString('base64url')
+    const record:RunnerRecord={...body,id:randomUUID(),sourceOwner,tokenHash:hash(token),enabled:true,createdAt:Date.now()}
+    this.store.put('_system','runner',record.id,record)
+    return {runner:this.summary(record),token}
+  }
+  summary(record:RunnerRecord) {
+    const {tokenHash:_secret,sourceOwner:_owner,...publicRecord}=record
+    return {...publicRecord,online:!!this.online.get(record.id)&&Date.now()-this.online.get(record.id)!.seen<35_000}
+  }
+  remove(id:string) {
+    const record=this.store.require<RunnerRecord>('_system','runner',id)
+    record.enabled=false;this.store.put('_system','runner',id,record);this.disconnect(id)
+  }
+  private disconnect(id:string) {
+    this.online.get(id)?.wake?.();this.online.delete(id)
+    for(const [key,connection] of this.connections) if(connection.runnerId===id){this.connections.delete(key);connection.onDisconnect()}
+    for(const [key,lease] of this.leases) if(lease.runnerId===id)this.leases.delete(key)
+    for(const pending of this.pending.get(id)?.values()??[]){clearTimeout(pending.timer);pending.reject(new HttpError(503,'执行节点已断开,需核对原执行','runner_offline'))}
+    this.pending.delete(id)
+  }
+  private request(id:string,kind:RunnerCommand['kind'],payload:Record<string,unknown>,valid:()=>boolean=()=>true):Promise<any> {
+    if(!this.online.has(id))return Promise.reject(new HttpError(503,'执行节点未连接','runner_offline'))
+    const queue=this.pending.get(id)??new Map<string,Pending>();this.pending.set(id,queue)
+    if(queue.size>=64)return Promise.reject(new HttpError(429,'执行节点请求过多','runner_busy'))
+    const command:RunnerCommand={id:randomUUID(),kind,payload,expiresAt:Date.now()+30000}
+    const bytes=Buffer.byteLength(JSON.stringify(command))
+    if(bytes>36*1024*1024)return Promise.reject(new HttpError(413,'执行命令超过传输限制','runner_payload_limit'))
+    if([...queue.values()].reduce((size,p)=>size+Buffer.byteLength(JSON.stringify(p.command)),bytes)>64*1024*1024)return Promise.reject(new HttpError(429,'执行节点待传输数据过多','runner_busy'))
+    return new Promise((resolve,reject)=>{
+      const timer=setTimeout(()=>{queue.delete(command.id);reject(new HttpError(504,'执行节点回应超时,请核对执行状态','runner_command_uncertain'))},30000)
+      queue.set(command.id,{command,resolve,reject,timer,nextDelivery:0,valid});this.online.get(id)?.wake?.()
+    })
+  }
+  target(owner:string,sourceNodeId:string):GatewayTarget|undefined {
+    const record=this.records().find(r=>r.enabled&&r.sourceNodeId===sourceNodeId&&(r.sourceOwner==='_system'||r.sourceOwner===owner))
+    if(!record)return undefined
+    const requireProfile=(profile:string)=>{
+      if(!this.auth.canUseSource(owner,sourceNodeId,profile)||!record.allowedProfiles.includes(profile))throw new HttpError(403,'执行节点未授权这个基础 Profile','runner_profile_forbidden')
+    }
+    const requestHTTP=async(path:string,options:UpstreamRequestOptions={}):Promise<UpstreamResponse>=>{
+      const profile=options.search?.get('profile')??'default'
+      if(path!=='/api/profiles'&&path!=='/api/status')requireProfile(profile)
+      const version=this.auth.pushAuthorizationVersion(owner)
+      const valid=()=>this.auth.isUserActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version&&this.store.get<RunnerRecord>('_system','runner',record.id)?.enabled===true
+      const result=await this.request(record.id,'http',{path,method:options.method??'GET',search:options.search?.toString(),body:options.body},valid)
+      if(!valid())throw new HttpError(403,'读取权限已失效','runner_authorization_revoked')
+      if(path==='/api/profiles') {
+        const parsed=JSON.parse(Buffer.from(result.body,'base64').toString())
+        parsed.profiles=(parsed.profiles??[]).filter((p:any)=>{const name=typeof p==='string'?p:p.name;return record.allowedProfiles.includes(name)&&this.auth.canUseSource(owner,sourceNodeId,name)})
+        result.body=Buffer.from(JSON.stringify(parsed)).toString('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')}},
+      runner:{id:record.id,
+        open:async(onEvent,onDisconnect)=>{
+          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}
+          let closed=false
+          return {rpc:async(method,params)=>{
+            if(closed)throw new Error('Runner gateway closed')
+            if(!valid())throw new HttpError(403,'本轮执行授权已失效','runner_command_not_admitted')
+            if(method==='session.create'||method==='session.resume')requireProfile(String(params.profile??'default'))
+            return this.request(record.id,'gateway.rpc',{connectionId:id,method,params})
+          },close:()=>{
+            closed=true;this.connections.delete(id)
+            for(const [key,pending] of this.pending.get(record.id)??[])if(pending.command.payload.connectionId===id){clearTimeout(pending.timer);pending.reject(new HttpError(409,'执行通道已关闭','runner_command_not_admitted'));this.pending.get(record.id)!.delete(key)}
+            void this.request(record.id,'gateway.close',{connectionId:id}).catch(()=>{})
+          }}
+        },
+        lease:async(options)=>{
+          requireProfile(options.profile);options.assertActive()
+          const id=randomUUID();this.leases.set(id,{runnerId:record.id,input:options,calls:new Map()})
+          let disposed=false
+          const dispose=async()=>{if(disposed)return;disposed=true;this.leases.delete(id);options.signal.removeEventListener('abort',abort);await this.request(record.id,'lease.close',{leaseId:id}).catch(()=>{})}
+          const abort=()=>{void dispose()};options.signal.addEventListener('abort',abort,{once:true})
+          try {
+            await this.request(record.id,'lease.create',{leaseId:id,profile:options.profile,workId:options.workId,session:options.session(),catalog:options.catalog()})
+            options.assertActive()
+            if(options.signal.aborted)throw new Error('执行已停止')
+            return {bind:async()=>{options.assertActive();await this.request(record.id,'lease.bind',{leaseId:id,session:options.session()});options.assertActive()},dispose} satisfies WorkspaceToolLease
+          } catch(error){await dispose();throw error}
+        },
+      }}
+  }
+  adminRouter():Router {
+    const router=new Router()
+    router.get('/api/app/admin/runners',ctx=>{const actor=this.auth.requireAdmin(ctx);ctx.body={protocol:RUNNER_PROTOCOL,runners:this.records().map(r=>this.summary(r)),sources:[{id:'local',name:'本地来源'},...this.store.list<import('./workspaceGateway.js').WorkspaceNode>(actor.id,'node').filter(n=>n.transport!=='paired-web').map(n=>({id:n.id,name:n.name}))]}})
+    router.post('/api/app/admin/runners',ctx=>{const actor=this.auth.requireAdmin(ctx);ctx.body=this.enroll(actor.id,(ctx.request as any).body);ctx.status=201})
+    router.delete('/api/app/admin/runners/:id',ctx=>{this.auth.requireAdmin(ctx);this.remove(ctx.params.id);ctx.body={ok:true}})
+    return router
+  }
+  middleware():Koa.Middleware {
+    return async(ctx,next)=>{
+      const match=/^\/api\/runner\/v1\/([0-9a-f-]{36})\/(poll|admit|result|event|tool)$/.exec(ctx.path)
+      if(!match)return next()
+      const record=this.store.get<RunnerRecord>('_system','runner',match[1]!),token=ctx.get('authorization').replace(/^Bearer /,'')
+      if(ctx.get('origin')||!record?.enabled||!ctx.get('authorization').startsWith('Bearer ')||!timingSafeEqual(Buffer.from(record.tokenHash),Buffer.from(hash(token))))
+        throw new HttpError(403,'执行节点授权无效','runner_unauthorized')
+      const instance=ctx.get('x-runner-instance')
+      if(!/^[0-9a-f-]{36}$/.test(instance)||ctx.get('x-runner-protocol')!==String(RUNNER_PROTOCOL))throw new HttpError(409,'执行节点协议不兼容','runner_protocol_mismatch')
+      let previous=this.online.get(record.id)
+      if(match[2]==='poll'&&previous?.instance===instance&&ctx.get('x-runner-reset')==='1'){this.disconnect(record.id);previous=undefined}
+      if(match[2]!=='poll'&&previous?.instance!==instance)throw new HttpError(409,'执行节点会话已更换','runner_instance_changed')
+      if(this.retired.get(record.id)?.has(instance))throw new HttpError(409,'执行节点实例已被替换','runner_instance_retired')
+      if(previous&&previous.instance!==instance){
+        const retired=this.retired.get(record.id)??new Set<string>()
+        if(retired.size>=256)throw new HttpError(409,'执行节点更换过于频繁,请重新注册','runner_instance_limit')
+        retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
+      }
+      if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
+      const state=this.online.get(record.id)??{instance,seen:Date.now(),epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();this.online.set(record.id,state)
+      ctx.set('Cache-Control','no-store')
+      if(match[2]==='poll') {
+        if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')
+        const available=()=>[...(this.pending.get(record.id)?.values()??[])].filter(p=>p.nextDelivery<=Date.now())
+        if(previous&&!available().length)await new Promise<void>(resolve=>{
+          const done=()=>{clearTimeout(timer);ctx.res.off('close',done);if(state.wake===done)state.wake=undefined;resolve()}
+          const retry=Math.min(15000,...[...(this.pending.get(record.id)?.values()??[])].map(p=>Math.max(1,p.nextDelivery-Date.now())))
+          const timer=setTimeout(done,retry);state.wake?.();state.wake=done;ctx.res.once('close',done)
+        })
+        if(this.online.get(record.id)!==state)throw new HttpError(409,'执行节点会话已更换','runner_instance_changed')
+        let bytes=0
+        const batch=available().filter(item=>{const size=Buffer.byteLength(JSON.stringify(item.command));if(bytes+size>38*1024*1024)return false;bytes+=size;return true});for(const item of batch)item.nextDelivery=Date.now()+5000
+        ctx.body={epoch:state.epoch,commands:batch.map(p=>p.command)};return
+      }
+      if(ctx.method!=='POST')throw new HttpError(405,'仅允许 POST','method_not_allowed')
+      let size=0;const chunks:Buffer[]=[]
+      for await(const chunk of ctx.req){size+=chunk.length;if(size>16*1024*1024)throw new HttpError(413,'执行节点响应过大','runner_payload_limit');chunks.push(Buffer.from(chunk))}
+      let body:any
+      try {body=JSON.parse(Buffer.concat(chunks).toString())}catch{throw new HttpError(400,'JSON 无效','invalid_json')}
+      if(!body||typeof body!=='object'||Array.isArray(body))throw new HttpError(400,'请求必须是对象','invalid_json')
+      if(match[2]==='admit') {
+        const pending=this.pending.get(record.id)?.get(body.id),command=pending?.command
+        let allowed=!!command&&command.expiresAt>Date.now()&&pending!.valid()
+        if(command?.kind==='gateway.rpc'||command?.kind==='gateway.open')allowed&&=this.connections.get(String(command.payload.connectionId))?.valid()===true
+        if(command?.kind==='lease.create'||command?.kind==='lease.bind') {
+          const lease=this.leases.get(String(command.payload.leaseId))
+          try {if(lease?.runnerId!==record.id)allowed=false;else lease.input.assertActive()}catch{allowed=false}
+        }
+        ctx.body={allowed};return
+      }
+      if(match[2]==='result') {
+        const waiter=this.pending.get(record.id)?.get(body.id)
+        if(waiter){clearTimeout(waiter.timer);this.pending.get(record.id)!.delete(body.id);body.error?waiter.reject(new HttpError(502,String(body.error.message??'执行节点失败').slice(0,1000),String(body.error.code??'runner_error'))):waiter.resolve(body.result)}
+        ctx.body={ok:true};return
+      }
+      if(match[2]==='event') {
+        const connection=this.connections.get(body.connectionId)
+        if(connection?.runnerId!==record.id)throw new HttpError(410,'执行通道已关闭','runner_connection_closed')
+        if(!connection.valid()){this.connections.delete(body.connectionId);connection.onDisconnect();throw new HttpError(410,'执行通道授权已失效','runner_connection_closed')}
+        if(body.closed){this.connections.delete(body.connectionId);connection.onDisconnect()}
+        else if(body.frame&&typeof body.frame.type==='string')connection.onEvent(body.frame)
+        ctx.body={ok:true};return
+      }
+      const value=parse(z.object({leaseId:z.string().uuid(),toolId:z.string().min(1).max(100),callId:z.string().min(1).max(200),arguments:z.record(z.string(),z.unknown())}).strict(),body)
+      const lease=this.leases.get(value.leaseId)
+      if(lease?.runnerId!==record.id)throw new HttpError(410,'工具授权已失效','runner_lease_expired')
+      lease.input.assertActive()
+      const fingerprint=hash(JSON.stringify([value.toolId,value.arguments]));let call=lease.calls.get(value.callId)
+      if(call&&call.fingerprint!==fingerprint)throw new HttpError(409,'重复调用参数不一致','idempotency_conflict')
+      if(!call){if(lease.calls.size>=128)throw new HttpError(429,'本轮工具调用已达上限','runner_call_limit');call={fingerprint,result:Promise.resolve().then(()=>lease.input.call(value.toolId,value.arguments,value.callId))};lease.calls.set(value.callId,call)}
+      const result=await call.result;lease.input.assertActive();ctx.body={result}
+    }
+  }
+  close(){clearInterval(this.watchdog);for(const id of [...this.online.keys()])this.disconnect(id)}
+}

+ 12 - 0
src/server/workspaceGateway.ts

@@ -25,6 +25,11 @@ export interface GatewayTarget {
   client: UpstreamClient
   session: Pick<UpstreamServiceSession, 'request' | 'webSocketCredential'>
   pairedToken?: string
+  runner?: {
+    id: string
+    open(onEvent:(frame:GatewayFrame)=>void,onDisconnect:()=>void):Promise<{rpc(method:string,params:Record<string,unknown>):Promise<any>;close():void}>
+    lease(input:import('./workspaceToolLease.js').LeaseInput):Promise<import('./workspaceToolLease.js').WorkspaceToolLease>
+  }
 }
 export interface GatewayFrame {
   type: string
@@ -32,6 +37,7 @@ export interface GatewayFrame {
   payload?: Record<string, any>
 }
 export class WorkspaceNodes {
+  runnerTarget?: (owner:string,nodeId:string)=>GatewayTarget|undefined
   sourceAllowed: (owner: string, nodeId: string, profile: string) => boolean = () => true
   requireSource(owner: string, agent: { nodeId: string; profile: string }): void {
     if (!this.sourceAllowed(owner, agent.nodeId, agent.profile)) throw new HttpError(403, '基础 Agent 未分配给当前账号', 'agent_source_forbidden')
@@ -71,6 +77,8 @@ export class WorkspaceNodes {
     ) as T
   }
   target(owner: string, id: string): GatewayTarget {
+    const runner=this.runnerTarget?.(owner,id)
+    if(runner)return runner
     if (id === 'local') return this.local
     const node = this.store.require<WorkspaceNode>(owner, 'node', id),
       key = `${owner}:${id}`
@@ -265,6 +273,7 @@ export class WorkspaceNodes {
 export class WorkspaceGateway {
   private socket?: WebSocket
   private paired?: WorkspacePairedChannel
+  private runner?: {rpc(method:string,params:Record<string,unknown>):Promise<any>;close():void}
   private pending = new Map<
     string,
     { resolve(value: any): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout> }
@@ -273,6 +282,7 @@ export class WorkspaceGateway {
   onDisconnect: () => void = () => {}
   constructor(readonly target: GatewayTarget) {}
   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.pairedToken) {
       this.paired = new WorkspacePairedChannel(this.target.url, this.target.pairedToken, this.target.client.fetchImpl,
         frame => this.onEvent(frame), () => this.onDisconnect())
@@ -341,6 +351,7 @@ export class WorkspaceGateway {
     await handshake
   }
   rpc(method: string, params: Record<string, unknown>): Promise<any> {
+    if(this.runner)return this.runner.rpc(method,params)
     if (this.paired) return this.paired.rpc(method, params)
     if (this.socket?.readyState !== WebSocket.OPEN)
       return Promise.reject(new Error('Hermes connection unavailable'))
@@ -363,6 +374,7 @@ export class WorkspaceGateway {
   close(): void {
     this.onDisconnect = () => {}
     this.paired?.close()
+    this.runner?.close()
     this.socket?.close()
   }
 }

+ 12 - 4
src/server/workspaceRuntime.ts

@@ -23,6 +23,7 @@ export { mentionedAgents } from './workspaceMentions.js'
 type Run = WorkspaceRun
 
 export interface WorkspaceBinding {
+  runnerId?: string
   remoteAgentId?: string
   id: string
   nodeId: string
@@ -172,6 +173,11 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       target = agent.remoteAgentId ? this.nodes.targetForAgent(owner,agent) : this.nodes.target(owner, agent.nodeId)
     const gateway = new WorkspaceGateway(target)
     let binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
+    const movedRunner=!!binding&&binding.runnerId!==target.runner?.id
+    if(movedRunner) {
+      if(recovering)throw new HttpError(409,'执行节点已变化,不能在另一节点重放原执行','runner_target_changed')
+      binding=undefined
+    }
     let message =
       run.currentMessageId
         ? this.store.require<Message>(owner, 'message', run.currentMessageId)
@@ -438,6 +444,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       )
         throw new Error('Hermes 会话身份不匹配')
       binding = {
+        runnerId: target.runner?.id,
         id: key,
         nodeId: agent.nodeId,
         remoteAgentId: agent.remoteAgentId,
@@ -561,7 +568,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         ]
           .filter(Boolean)
           .join('\n\n')
-        const text = c.kind === 'group' ? this.contextText(owner, c, agent, run, binding.contextSeq ?? 0) : trigger.content
+        const text = c.kind === 'group' ? this.contextText(owner, c, agent, run, binding.contextSeq ?? 0, movedRunner)
+          : movedRunner ? `执行节点已切换。以下是原会话的近期记录,旧路径和执行状态需要在当前节点重新核实;不要重新执行已完成的操作。\n\n${this.contextText(owner,c,agent,run,0,true)}` : trigger.content
         const admission = this.getWork(owner, run.id)
         if (!this.store.require<Conversation>(owner, 'conversation', c.id).memberIds.includes(agent.id)) {
           admission.status = 'interrupted'; admission.error = '执行前成员已移除'; this.saveWork(owner, admission)
@@ -583,7 +591,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           this.store.put(owner, 'binding', key, binding)
         } catch (error) {
           // A JSON-RPC error is a definitive rejection, not a lost receipt.
-          if (error instanceof HttpError && error.code === 'gateway_rejected') { submitted = false; const current = this.getWork(owner, run.id); current.submitted = false; this.saveWork(owner, current) }
+          if (error instanceof HttpError && ['gateway_rejected','runner_command_not_admitted'].includes(error.code??'')) { submitted = false; const current = this.getWork(owner, run.id); current.submitted = false; this.saveWork(owner, current) }
           throw error
         }
       }
@@ -598,7 +606,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     if(!this.userActive(owner)||(run.authorizationVersion!==undefined&&run.authorizationVersion!==this.authorizationVersion(owner)))
       throw new HttpError(403,'本轮账号授权已失效','run_authorization_revoked')
   }
-  private contextText(owner: string, c: Conversation, agent: Agent, work: Work, after: number): string {
+  private contextText(owner: string, c: Conversation, agent: Agent, work: Work, after: number, includeOwn = false): string {
     const rootTrigger = this.store.require<Message>(owner, 'message', work.messageId)
     const roots = new Map(this.store.list<Run>(owner, 'run').filter(r => r.conversationId === c.id && r.conversationTaskId === work.conversationTaskId).map(r => [r.id, this.store.get<Message>(owner, 'message', r.messageId)?.seq ?? 0]))
     const history = this.store.messages(owner, c.id, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, true, work.conversationTaskId)
@@ -606,7 +614,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     work.contextThroughSeq = unfinished ? unfinished.seq - 1 : work.triggerSeq
     const eligible = history
       .filter(m => m.seq > after && m.seq <= work.triggerSeq && m.visible !== false && ['complete', 'failed', 'interrupted'].includes(m.status)
-        && !(m.role === 'assistant' && m.agentId === agent.id) && (!m.runId || (roots.get(m.runId) ?? 0) <= rootTrigger.seq))
+        && (includeOwn || !(m.role === 'assistant' && m.agentId === agent.id)) && (!m.runId || (roots.get(m.runId) ?? 0) <= rootTrigger.seq))
     const selected: string[] = []
     let size = 0, omitted = 0
     for (const message of [...eligible].reverse()) {

+ 2 - 2
src/server/workspaceTeamTools.ts

@@ -58,7 +58,7 @@ export class WorkspaceTeamTools {
 
   async requireAvailable(owner: string, agent: Pick<Agent, 'nodeId' | 'profile' | 'remoteAgentId'>): Promise<void> {
     this.nodes.requireSource(owner, agent)
-    if (agent.nodeId !== 'local' || 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')
     await requireTeamToolBridge(this.nodes.target(owner, agent.nodeId), agent.profile)
     this.nodes.requireSource(owner, agent)
@@ -71,7 +71,7 @@ export class WorkspaceTeamTools {
     const c = this.store.require<Conversation>(owner, 'conversation', work.conversationId)
     const root = this.store.require<Run>(owner, 'run', work.runId)
     this.nodes.requireSource(owner, agent)
-    if (agent.canManageTeam !== true || agent.archived || agent.nodeId !== 'local' || agent.remoteAgentId || root.assignmentId
+    if (agent.canManageTeam !== true || agent.archived || (agent.nodeId !== 'local' && !this.nodes.runnerTarget?.(owner,agent.nodeId)) || agent.remoteAgentId || root.assignmentId
       || work.teamManagementRevision !== agent.revision
       || (root.authorizationVersion !== undefined && root.authorizationVersion !== this.runtime.authorizationVersion(owner))
       || c.archived || !c.memberIds.includes(agent.id) || work.cancelRequested || root.stopRequested

+ 5 - 4
src/server/workspaceToolLease.ts

@@ -23,7 +23,7 @@ async function bridgeRequest(target: GatewayTarget, path: string, body: unknown)
 }
 
 export async function requireTeamToolBridge(target: GatewayTarget, profile: string): Promise<void> {
-  if (target.pairedToken || !isLoopbackHost(target.url.hostname))
+  if (!target.runner && (target.pairedToken || !isLoopbackHost(target.url.hostname)))
     throw new HttpError(409, '组队发起者的 Hermes 必须与 Web 服务运行在同一台机器,并使用回环地址连接。', 'team_tools_local_required')
   let ready = false
   try {
@@ -39,7 +39,7 @@ export interface WorkspaceToolLease {
   dispose(): Promise<void>
 }
 
-interface LeaseInput {
+export interface LeaseInput {
   target: GatewayTarget
   profile: string
   workId: string
@@ -47,12 +47,13 @@ interface LeaseInput {
   signal: AbortSignal
   assertActive(): void
   catalog(): Catalog
-  call(toolId: string, args: unknown): Promise<unknown>
+  call(toolId: string, args: unknown, callId?:string): Promise<unknown>
   onFailure(error: Error): void
 }
 
 /** A private loopback listener; no browser credentials or authority in prompts. */
 export async function createWorkspaceToolLease(input: LeaseInput): Promise<WorkspaceToolLease> {
+  if(input.target.runner)return input.target.runner.lease(input)
   const token = randomBytes(32).toString('base64url'), tokenHash = createHash('sha256').update(token).digest()
   const generation = `${input.workId}:${randomUUID()}`
   let expiresAt = Date.now() + TTL, closed = false, url = ''
@@ -126,7 +127,7 @@ export async function createWorkspaceToolLease(input: LeaseInput): Promise<Works
       const result = Promise.resolve().then(async () => {
         assertActive()
         try {
-          const value = await input.call(tool.id, call.arguments)
+          const value = await input.call(tool.id, call.arguments, call.callId)
           return { content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value }
         } catch (error) {
           return { content: [{ type: 'text', text: JSON.stringify({ error: error instanceof HttpError ? error.message : '团队操作失败,请检查当前状态后再重试。', code: error instanceof HttpError ? error.code : 'team_tool_failed' }) }], isError: true }

+ 28 - 0
src/shared/runner.ts

@@ -0,0 +1,28 @@
+export const RUNNER_PROTOCOL = 1
+export interface RunnerRecord {
+  id: string
+  name: string
+  sourceNodeId: string
+  sourceOwner: string
+  allowedProfiles: string[]
+  tokenHash: string
+  enabled: boolean
+  createdAt: number
+}
+export interface RunnerCommand {
+  id: string
+  kind: 'http' | 'gateway.open' | 'gateway.rpc' | 'gateway.close' | 'lease.create' | 'lease.bind' | 'lease.close'
+  payload: Record<string, unknown>
+  expiresAt: number
+}
+export interface RunnerConfiguration {
+  protocol: 1
+  serverURL: string
+  runnerId: string
+  token: string
+  hermesURL: string
+  allowedProfiles: string[]
+  artifactRoots: string[]
+  allowInsecureLan?: boolean
+  hermesCredentials?: {username:string;password:string}
+}

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

@@ -65,3 +65,40 @@ test('task plans show owners and keep independent text and attachment drafts whe
   await page.getByRole('button',{name:'停止任务',exact:true}).click()
   await expect(page.locator('.task-plan')).toContainText('已停止')
 })
+
+test('registers an execution node, downloads its private config and disables it',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.getByRole('button',{name:/^(打开设置中心|设置与模式)$/}).first().click()
+  if(await page.getByRole('menuitem',{name:'进入设置',exact:true}).isVisible())await page.getByRole('menuitem',{name:'进入设置',exact:true}).click()
+  const dialog=page.getByRole('dialog',{name:'设置中心'})
+  await dialog.getByRole('button',{name:'Hermes 连接',exact:true}).click()
+  await dialog.locator('details.runner-settings summary').click()
+  const panel=dialog.getByRole('region',{name:'执行节点设置'})
+  await panel.getByRole('textbox',{name:'节点名称',exact:true}).fill('隔离测试 Mac')
+  await panel.getByRole('textbox',{name:'允许的 Profile'}).fill('default')
+  await panel.scrollIntoViewIfNeeded()
+  await page.screenshot({path:testInfo.outputPath('runner-registration.png')})
+  await panel.getByRole('button',{name:'注册节点',exact:true}).click()
+  await expect(panel).toContainText('待连接')
+  const downloaded=page.waitForEvent('download')
+  await panel.getByRole('button',{name:'下载 Runner 配置',exact:true}).click()
+  const download=await downloaded
+  expect(download.suggestedFilename()).toBe('runner.json')
+  const stream=await download.createReadStream();const chunks:Buffer[]=[]
+  for await(const chunk of stream!)chunks.push(Buffer.from(chunk))
+  const configuration=JSON.parse(Buffer.concat(chunks).toString())
+  expect(configuration).toMatchObject({protocol:1,allowedProfiles:['default'],artifactRoots:[]})
+  expect(configuration.token.length).toBeGreaterThan(32)
+  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('tokenHash')
+  await page.setViewportSize({width:390,height:844})
+  await panel.getByRole('button',{name:'停用 隔离测试 Mac'}).scrollIntoViewIfNeeded()
+  await page.screenshot({path:testInfo.outputPath('runner-registered-mobile.png')})
+  await panel.getByRole('button',{name:'停用 隔离测试 Mac'}).click()
+  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)
+})

+ 15 - 0
tests/live/workspaceTeamTools.live.test.ts

@@ -5,6 +5,8 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { randomUUID } from 'node:crypto'
 import request from 'supertest'
+import {createServer, type Server} from 'node:http'
+import {RunnerAgent} from '../../src/runner/agent'
 import { createApplication } from '../../src/server/app'
 import { loadServerConfig } from '../../src/server/config'
 import type { WorkspaceAgent, WorkspaceConversation, WorkspaceMessage, WorkspaceRun } from '../../src/shared/workspace'
@@ -19,6 +21,8 @@ it.skipIf(!url)('uses actual Hermes auth, sessions and native plugin tools to cr
   const proof = await marker.json()
   expect(proof).toMatchObject({fixture:true,kind:'hermes-tool-bridge'})
   const home=mkdtempSync(join(tmpdir(),'yaoyao-team-wire-client-'))
+  let runnerServer:Server|undefined,runnerDone:Promise<void>|undefined
+  const runnerAbort=new AbortController()
   const app=createApplication({config:loadServerConfig({HERMES_YAOYAO_HOME:home,HERMES_YAOYAO_UPSTREAM:url!,HERMES_YAOYAO_UPSTREAM_USERNAME:'fixture',HERMES_YAOYAO_UPSTREAM_PASSWORD:'yaoyao-fixture-password'})})
   try {
     const bootstrap=await request(app.app.callback()).get('/api/app/bootstrap').set('Host','127.0.0.1:15300').expect(200)
@@ -26,6 +30,15 @@ it.skipIf(!url)('uses actual Hermes auth, sessions and native plugin tools to cr
       .set('Cookie',(bootstrap.headers['set-cookie'] as unknown as string[]).map(c=>c.split(';')[0]).join('; '))
       .set('X-CSRF-Token',bootstrap.body.csrfToken).send({username:'team-fixture',password:'team-fixture-password'}).expect(200)
     const owner=setup.body.user.id as string
+    if(process.env.HERMES_TEAM_RUNNER==='1') {
+      runnerServer=createServer(app.app.callback())
+      await new Promise<void>(resolve=>runnerServer!.listen(0,'127.0.0.1',resolve))
+      app.config.port=(runnerServer.address() as {port:number}).port
+      const registered=app.runners.enroll(owner,{name:'真实协议 Runner',allowedProfiles:['default']})
+      const runner=new RunnerAgent({protocol:1,serverURL:`http://127.0.0.1:${(runnerServer.address() as {port:number}).port}`,runnerId:registered.runner.id,token:registered.token,hermesURL:url!,allowedProfiles:['default'],artifactRoots:[],hermesCredentials:{username:'fixture',password:'yaoyao-fixture-password'}},home)
+      runnerDone=runner.run(runnerAbort.signal);void runnerDone.catch(()=>{})
+      await expect.poll(()=>app.runners.summary(app.runners.records()[0]!).online).toBe(true)
+    }
     const manager=app.workspace.createAgent(owner,{name:'组队老板',profile:'default',canManageTeam:true})
     await app.workspaceRuntime.teamTools.requireAvailable(owner,manager)
     const direct=app.workspace.list<WorkspaceConversation>(owner,'conversation').find(c=>c.memberIds[0]===manager.id)!
@@ -87,6 +100,8 @@ it.skipIf(!url)('uses actual Hermes auth, sessions and native plugin tools to cr
       expect(JSON.parse(a.at(-1)!.content).calls[0].native_name).not.toBe(JSON.parse(b.at(-1)!.content).calls[0].native_name)
     } finally { clearTimeout(deadline);spy.mockRestore() }
   } finally {
+    runnerAbort.abort();await runnerDone?.catch(()=>{})
+    if(runnerServer)await new Promise<void>(resolve=>{runnerServer!.close(()=>resolve());runnerServer!.closeAllConnections()})
     app.close()
     // Let asynchronous loopback lease unbinds finish before removing the fixture store.
     await new Promise(resolve=>setTimeout(resolve,100))

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

@@ -0,0 +1,231 @@
+// @vitest-environment node
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import Koa from 'koa'
+import { createServer, type Server } from 'node:http'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { spawn } from 'node:child_process'
+import { once } from 'node:events'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { WebSocketServer, type WebSocket } from 'ws'
+import { WorkspaceStore } from '../../src/server/workspaceStore'
+import { LocalAuthStore } from '../../src/server/localAuth'
+import { RunnerHub } from '../../src/server/runnerHub'
+import { RunnerAgent } from '../../src/runner/agent'
+import { WorkspaceGateway, type GatewayTarget, type GatewayFrame } from '../../src/server/workspaceGateway'
+import { createWorkspaceToolLease } from '../../src/server/workspaceToolLease'
+import { HttpError } from '../../src/server/errors'
+import type { RunnerCommand, RunnerConfiguration } from '../../src/shared/runner'
+
+class Auth extends LocalAuthStore {
+  version=1
+  allowed=true
+  override isUserActive(){return true}
+  override pushAuthorizationVersion(){return this.version}
+  override canUseSource(){return this.allowed}
+}
+let home:string,store:WorkspaceStore,auth:Auth,hub:RunnerHub,runner:RunnerAgent,config:RunnerConfiguration
+let web:Server,hermes:Server,ws:WebSocketServer,controller:AbortController,running:Promise<void>
+let target:GatewayTarget,bindings:any[],rpc:Array<{method:string;params:any}>,frames:GatewayFrame[],sockets:WebSocket[]
+const gateways:WorkspaceGateway[]=[]
+const listen=(server:Server)=>new Promise<string>(resolve=>server.listen(0,'127.0.0.1',()=>resolve(`http://127.0.0.1:${(server.address() as {port:number}).port}`)))
+const close=(server:Server)=>new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()})
+beforeEach(async()=>{
+  home=mkdtempSync(join(tmpdir(),'yaoyao-runner-'));store=new WorkspaceStore(home);auth=new Auth(home)
+  bindings=[];rpc=[];frames=[];sockets=[]
+  hermes=createServer(async(req,res)=>{
+    let body='';for await(const part of req)body+=part
+    if(req.url?.endsWith('/bind'))bindings.push(JSON.parse(body))
+    res.setHeader('Content-Type','application/json')
+    res.end(JSON.stringify(req.url==='/api/auth/ws-ticket'?{ticket:'fixture'}:req.url==='/api/profiles'?{profiles:['default',{name:'writer'},{name:'secret'}]}:req.url?.endsWith('/capabilities')?{version:1,ready:true,in_process:true,native_tools:true}:{ok:true,native_tools:true,terminal:{cwd:'/fixture/workspace'}}))
+  })
+  const hermesURL=await listen(hermes)
+  ws=new WebSocketServer({server:hermes})
+  ws.on('connection',socket=>{
+    sockets.push(socket);socket.send(JSON.stringify({method:'event',params:{type:'gateway.ready'}}))
+    socket.on('message',raw=>{
+      const value=JSON.parse(String(raw));rpc.push(value)
+      const result=['session.create','session.resume'].includes(value.method)?{session_id:randomUUID(),stored_session_id:randomUUID(),info:{profile_name:value.params.profile}}:{ok:true}
+      socket.send(JSON.stringify({id:value.id,result}))
+    })
+  })
+  hub=new RunnerHub(store,auth,{url:new URL(hermesURL)} as GatewayTarget)
+  const registered=hub.enroll('owner',{name:'测试电脑',allowedProfiles:['default','writer']})
+  const app=new Koa();app.use(async(ctx,next)=>{try{await next()}catch(error){ctx.status=error instanceof HttpError?error.status:500;ctx.body={code:error instanceof HttpError?error.code:'error'}}});app.use(hub.middleware())
+  web=createServer(app.callback());const serverURL=await listen(web)
+  config={protocol:1,serverURL,runnerId:registered.runner.id,token:registered.token,hermesURL,allowedProfiles:['default','writer'],artifactRoots:[]}
+  runner=new RunnerAgent(config,home);controller=new AbortController();running=runner.run(controller.signal);void running.catch(()=>{})
+  await vi.waitFor(()=>expect(hub.summary(hub.records()[0]!).online).toBe(true))
+  await vi.waitFor(()=>expect((runner as any).serverEpoch).toBeTruthy())
+  target=hub.target('owner','local')!
+})
+afterEach(async()=>{
+  for(const gateway of gateways.splice(0))gateway.close()
+  controller.abort();await running.catch(()=>{});hub.close()
+  for(const socket of ws.clients)socket.terminate()
+  await new Promise<void>(resolve=>ws.close(()=>resolve()));await close(hermes);await close(web)
+  store.close();rmSync(home,{recursive:true,force:true})
+})
+async function channel(){
+  const gateway=new WorkspaceGateway(target);gateways.push(gateway);gateway.onEvent=frame=>frames.push(frame)
+  await gateway.connect();const session=await gateway.rpc('session.create',{profile:'default'})
+  return {gateway,session}
+}
+async function machine(path:string,body?:unknown,overrides:Record<string,string>={}){
+  return fetch(`${config.serverURL}/api/runner/v1/${config.runnerId}/${path}`,{
+    method:body===undefined?'GET':'POST',headers:{Authorization:`Bearer ${config.token}`,'x-runner-instance':runner.instance,'x-runner-protocol':'1','x-runner-epoch':(runner as any).serverEpoch,'Content-Type':'application/json',...overrides},...(body===undefined?{}:{body:JSON.stringify(body)})
+  })
+}
+it('connects through outbound transport, filters profiles and forwards only owned session events',async()=>{
+  const profiles=await target.session.request('/api/profiles')
+  expect(JSON.parse(profiles.body.toString()).profiles).toEqual(['default',{name:'writer'}])
+  const {gateway,session}=await channel()
+  await expect(gateway.rpc('session.create',{profile:'secret'})).rejects.toMatchObject({code:'runner_profile_forbidden'})
+  await expect(gateway.rpc('prompt.submit',{session_id:'someone-else',text:'no'})).rejects.toMatchObject({code:'runner_session_forbidden'})
+  await gateway.rpc('prompt.submit',{session_id:session.session_id,text:'hello'})
+  sockets[0]!.send(JSON.stringify({method:'event',params:{type:'message.delta',session_id:'someone-else',payload:{text:'private'}}}))
+  sockets[0]!.send(JSON.stringify({method:'event',params:{type:'message.delta',session_id:session.session_id,payload:{text:'hello'}}}))
+  await vi.waitFor(()=>expect(frames.some(f=>f.payload?.text==='hello')).toBe(true))
+  expect(frames.some(f=>f.payload?.text==='private')).toBe(false)
+})
+it('mounts loopback-only tools with stable call ids and revokes an active grant',async()=>{
+  const {session}=await channel(),abort=new AbortController(),call=vi.fn(async()=>({agent:{id:'created'}}))
+  let active=true
+  const lease=await createWorkspaceToolLease({target,profile:'default',workId:'work',session:()=>({runtimeId:session.session_id,storedId:session.stored_session_id}),signal:abort.signal,
+    assertActive:()=>{if(!active)throw new HttpError(403,'撤权','revoked')},catalog:()=>[{id:'workspace_create_agent',name:'workspace_create_agent',description:'create',inputSchema:{type:'object'}}],call,onFailure:()=>{}})
+  await lease.bind();const binding=bindings[0]
+  const toolHTTP=(path:string,body:unknown)=>fetch(binding.bridge_url+path,{method:'POST',headers:{Authorization:`Bearer ${binding.token}`,'Content-Type':'application/json'},body:JSON.stringify(body)})
+  const catalog=await (await toolHTTP('/tools/list',{})).json() as any
+  const body={callId:'same-call',toolId:catalog.tools[0].id,arguments:{name:'worker'}}
+  const first=await (await toolHTTP('/tools/call',body)).json()
+  expect(await (await toolHTTP('/tools/call',body)).json()).toEqual(first)
+  expect(call).toHaveBeenCalledExactlyOnceWith('workspace_create_agent',{name:'worker'},'same-call')
+  active=false
+  expect(await (await toolHTTP('/tools/call',{...body,callId:'late-call'})).json()).toMatchObject({isError:true})
+  expect(call).toHaveBeenCalledTimes(1)
+  await lease.dispose()
+})
+it('denies browser credentials, stale epochs, foreign lease ids and unauthorized HTTP operations',async()=>{
+  expect((await machine('admit',{id:randomUUID()},{Origin:config.serverURL})).status).toBe(403)
+  expect((await machine('admit',{id:randomUUID()},{Authorization:'Bearer wrong'})).status).toBe(403)
+  expect((await machine('admit',{id:randomUUID()},{'x-runner-epoch':'old'})).status).toBe(409)
+  expect((await machine('tool',{leaseId:randomUUID(),callId:'test',toolId:'workspace_create_agent',arguments:{}})).status).toBe(410)
+  await expect(target.session.request('/api/config',{method:'POST',body:{}})).rejects.toMatchObject({code:'runner_http_forbidden'})
+  await expect(target.session.request('/api/files/download',{search:new URLSearchParams({profile:'default',path:'/etc/hosts'})})).rejects.toMatchObject({code:'runner_artifact_forbidden'})
+})
+it('does not execute a queued command after account authorization changes',async()=>{
+  const {gateway,session}=await channel()
+  auth.version++
+  await expect(gateway.rpc('prompt.submit',{session_id:session.session_id,text:'late'})).rejects.toMatchObject({code:'runner_command_not_admitted'})
+  expect(rpc.filter(r=>r.method==='prompt.submit')).toHaveLength(0)
+})
+it('keeps one result for a command and refuses conflicting or unfinished replay after restart',async()=>{
+  const seen:RunnerCommand[]=[];const execute=(runner as any).execute.bind(runner)
+  vi.spyOn(runner as any,'execute').mockImplementation(async(command:any)=>{seen.push(command);return execute(command)})
+  await channel()
+  const command=seen.find(c=>c.kind==='gateway.rpc')!
+  const before=rpc.length
+  expect(await runner.handle(command)).toHaveProperty('result.session_id')
+  expect(rpc).toHaveLength(before)
+  await expect(runner.handle({...command,payload:{changed:true}})).rejects.toMatchObject({code:'idempotency_conflict'})
+  const incomplete={...command,id:randomUUID()}
+  const {createHash}=await import('node:crypto')
+  ;(runner as any).db.prepare("INSERT INTO commands VALUES(?,?,'started',NULL,?)").run(incomplete.id,createHash('sha256').update(JSON.stringify(incomplete)).digest('hex'),Date.now())
+  controller.abort();await running
+  runner=new RunnerAgent(config,home);controller=new AbortController();running=runner.run(controller.signal);void running.catch(()=>{})
+  await expect(runner.handle(incomplete)).rejects.toMatchObject({code:'runner_command_uncertain'})
+  expect(await runner.handle(command)).toHaveProperty('result.session_id')
+  expect(rpc).toHaveLength(before)
+})
+it('closes a pending gateway before a delayed handshake can revive it',async()=>{
+  const connect=WorkspaceGateway.prototype.connect
+  let release!:()=>void
+  const gate=new Promise<void>(resolve=>{release=resolve})
+  vi.spyOn(WorkspaceGateway.prototype,'connect').mockImplementationOnce(async function(this:WorkspaceGateway){await gate;return connect.call(this)})
+  const connectionId=randomUUID(),request=(hub as any).request.bind(hub)
+  // The control plane normally installs this owner record before opening.
+  ;(hub as any).connections.set(connectionId,{runnerId:config.runnerId,valid:()=>true,onEvent:()=>{},onDisconnect:()=>{}})
+  const opening=request(config.runnerId,'gateway.open',{connectionId}).catch((error:unknown)=>error)
+  await vi.waitFor(()=>expect((runner as any).connections.has(connectionId)).toBe(true))
+  await request(config.runnerId,'gateway.close',{connectionId})
+  release()
+  expect(await opening).toBeInstanceOf(Error)
+  expect((runner as any).connections.has(connectionId)).toBe(false)
+})
+it('fences a replaced process and does not route to the host when its runner goes offline',async()=>{
+  const {gateway}=await channel();const disconnected=vi.fn();gateway.onDisconnect=disconnected
+  const oldInstance=runner.instance
+  controller.abort();await running
+  runner=new RunnerAgent(config,home);controller=new AbortController();running=runner.run(controller.signal);void running.catch(()=>{})
+  await vi.waitFor(()=>expect(disconnected).toHaveBeenCalled())
+  expect((await machine('poll',undefined,{'x-runner-instance':oldInstance})).status).toBe(409)
+  hub.remove(config.runnerId)
+  await expect(target.session.request('/api/status')).rejects.toMatchObject({code:'runner_offline'})
+})
+
+it('resets the hub epoch after a transport failure and closes the obsolete gateways',async()=>{
+  const {gateway}=await channel(),disconnected=vi.fn();gateway.onDisconnect=disconnected
+  const epoch=(runner as any).serverEpoch
+  await (runner as any).disconnect()
+  // Wake the outstanding poll. Its response must be ignored after disconnect.
+  ;(hub as any).online.get(config.runnerId).wake?.()
+  await vi.waitFor(()=>expect((runner as any).serverEpoch).not.toBe(epoch))
+  expect(disconnected).toHaveBeenCalledTimes(1)
+  const next=await channel()
+  expect(await next.gateway.rpc('session.usage',{session_id:next.session.session_id})).toEqual({ok:true})
+})
+it('a closed account channel cannot interrupt an unrelated connection',async()=>{
+  const first=await channel(),second=await channel()
+  const entries=[...(hub as any).connections.entries()] as Array<[string,any]>
+  entries[0]![1].valid=()=>false
+  sockets[0]!.send(JSON.stringify({method:'event',params:{type:'message.delta',session_id:first.session.session_id,payload:{text:'revoked'}}}))
+  await vi.waitFor(()=>expect((runner as any).connections.size).toBe(1))
+  expect((runner as any).connected).toBe(true)
+  expect(await second.gateway.rpc('prompt.submit',{session_id:second.session.session_id,text:'continue'})).toEqual({ok:true})
+  expect(rpc.filter(r=>r.method==='session.interrupt'&&r.params.session_id===second.session.session_id)).toHaveLength(0)
+})
+it('rechecks admission after a queued prompt loses its account permission',async()=>{
+  const {gateway,session}=await channel()
+  const api=(runner as any).api.bind(runner)
+  let release!:()=>void,waiting=false
+  const gate=new Promise<void>(resolve=>{release=resolve})
+  vi.spyOn(runner as any,'api').mockImplementation(async(path:any,body:any)=>{if(path==='admit'){waiting=true;await gate}return api(path,body)})
+  const result=gateway.rpc('prompt.submit',{session_id:session.session_id,text:'must not run'}).catch(error=>error)
+  await vi.waitFor(()=>expect(waiting).toBe(true));auth.version++;release()
+  expect(await result).toMatchObject({code:'runner_command_not_admitted'})
+  expect(rpc.filter(r=>r.method==='prompt.submit')).toHaveLength(0)
+})
+it('does not recreate a lease when cancellation overtakes its admitted creation',async()=>{
+  const {session}=await channel(),abort=new AbortController()
+  const execute=(runner as any).execute.bind(runner)
+  let release!:()=>void,waiting=false
+  const gate=new Promise<void>(resolve=>{release=resolve})
+  vi.spyOn(runner as any,'execute').mockImplementation(async(command:any)=>{if(command.kind==='lease.create'){waiting=true;await gate}return execute(command)})
+  const result=createWorkspaceToolLease({target,profile:'default',workId:'cancel-race',session:()=>({runtimeId:session.session_id,storedId:session.stored_session_id}),signal:abort.signal,
+    assertActive:()=>{if(abort.signal.aborted)throw new Error('stopped')},catalog:()=>[],call:async()=>({}),onFailure:()=>{}}).catch(error=>error)
+  await vi.waitFor(()=>expect(waiting).toBe(true));abort.abort()
+  await vi.waitFor(()=>expect((runner as any).closedLeases.size).toBe(1));release()
+  expect(await result).toBeInstanceOf(Error)
+  expect((runner as any).leases.size).toBe(0)
+  expect(bindings).toHaveLength(0)
+})
+
+it.each([0o600,0o644])('runs the CLI with private configuration and rejects broad permissions (%s)',async(mode)=>{
+  controller.abort();await running
+  const file=join(home,'runner.json');writeFileSync(file,JSON.stringify(config),{mode})
+  const bundle=process.env.RUNNER_BUNDLE_PATH
+  const args=bundle?[bundle,'--config',file]:['--import','tsx','src/runner/index.ts','--config',file]
+  const child=spawn(process.execPath,args,{stdio:['ignore','ignore','pipe']})
+  let output='';child.stderr?.on('data',data=>{output+=String(data)})
+  const exited=once(child,'exit')
+  try {
+    if(mode===0o644){expect((await exited)[0]).not.toBe(0);expect(output).toContain('chmod 600');return}
+    await vi.waitFor(()=>{if(child.exitCode!==null)throw new Error(`Runner 提前退出 ${child.exitCode}: ${output}`);expect(output).toContain('执行节点已连接')},{timeout:7000})
+    expect((await target.session.request('/api/profiles')).status).toBe(200)
+    child.kill('SIGTERM')
+    expect((await exited)[0]).toBe(0)
+    expect(output).toContain('执行节点已停止')
+    expect(output).not.toContain(config.token)
+  }finally{if(child.exitCode===null&&child.signalCode===null){child.kill('SIGTERM');await exited}}
+},15000)

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

@@ -141,6 +141,21 @@ async function finished(id: string) {
   return store.require<WorkspaceRun>(owner, 'run', id)
 }
 describe('Web-owned workspace', () => {
+  it('restores recent user and assistant context when moving a completed conversation to another runner',async()=>{
+    const a=agent('移动成员'),c=direct(a.id)
+    await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'保存研究结论'}).id)
+    const binding=store.list<any>(owner,'binding')[0]!
+    store.put(owner,'binding',binding.id,{...binding,runnerId:'previous-runner'})
+    const before=requests.length
+    await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'继续后续分析'}).id)
+    const next=requests.slice(before)
+    expect(next.some(r=>r.method==='session.resume')).toBe(false)
+    const text=next.find(r=>r.method==='prompt.submit')!.params.text
+    expect(text).toContain('执行节点已切换')
+    expect(text).toContain('保存研究结论')
+    expect(text).toContain('移动成员(complete):完成')
+    expect(text).toContain('继续后续分析')
+  })
   it('uses each base profile cwd and replaces historical cwd before the next Bot turn', async () => {
     configuredCwds.set('default', '/profile/first')
     configuredCwds.set('other', '/profile/other')

+ 1 - 1
tsconfig.server.json

@@ -16,6 +16,6 @@
     "skipLibCheck": true,
     "ignoreDeprecations": "6.0"
   },
-  "include": ["src/server/**/*.ts", "src/shared/**/*.ts"],
+  "include": ["src/server/**/*.ts", "src/shared/**/*.ts", "src/runner/**/*.ts"],
   "exclude": ["tests", "src/client"]
 }