Browse Source

合并 Bot 自主组队、本地虚拟机及桌面服务功能到主分支

LSamien 1 month ago
parent
commit
72fc6a4727
100 changed files with 6964 additions and 117 deletions
  1. 4 0
      .dockerignore
  2. 5 0
      .gitignore
  3. 3 1
      Dockerfile
  4. 36 0
      bin/desktop-service-sync.mjs
  5. 27 62
      bin/hermes-yaoyao-updater.mjs
  6. 78 0
      bin/lib/runtime-release.mjs
  7. 57 0
      bin/lib/service-data.mjs
  8. 334 0
      bin/lib/service-update.mjs
  9. 96 0
      deploy/computer/Dockerfile
  10. 11 0
      deploy/computer/prepare-firefox.sh
  11. 1 0
      desktop/boot.css
  12. 5 0
      desktop/boot.html
  13. 19 0
      desktop/boot.js
  14. 41 0
      desktop/core-lifecycle.test.mjs
  15. 23 0
      desktop/credentials.mjs
  16. 22 0
      desktop/credentials.test.mjs
  17. 65 0
      desktop/keychain-helper.swift
  18. 121 0
      desktop/lifecycle.test.mjs
  19. 226 0
      desktop/main.mjs
  20. 12 0
      desktop/preferences.mjs
  21. 18 0
      desktop/preferences.test.mjs
  22. 6 0
      desktop/preload.cjs
  23. 98 0
      desktop/runner-manager.mjs
  24. 48 0
      desktop/runner-manager.test.mjs
  25. 259 0
      desktop/service-manager.mjs
  26. 71 0
      desktop/service-manager.test.mjs
  27. 31 0
      desktop/service-sync.mjs
  28. 141 0
      desktop/service-sync.test.mjs
  29. 44 0
      desktop/startup-feedback.test.mjs
  30. 91 0
      docs/agent-platform-design.md
  31. 120 0
      docs/agent-platform-implementation.md
  32. 54 0
      docs/app-web-sync.md
  33. 78 0
      docs/bot-agent-teams.md
  34. 29 0
      docs/computer-control.md
  35. 50 0
      docs/computer-maintenance.md
  36. 35 0
      docs/computer-network.md
  37. 34 0
      docs/computer-provider.md
  38. 59 0
      docs/computer-worker.md
  39. 15 0
      docs/desktop-lifecycle.md
  40. 44 0
      docs/local-vm.md
  41. 59 0
      docs/macos-app.md
  42. 59 0
      docs/runner.md
  43. BIN
      docs/screenshots/local-vm/agent-panel.png
  44. BIN
      docs/screenshots/local-vm/interactive-desktop.png
  45. BIN
      docs/screenshots/local-vm/phone-chat-entry.png
  46. BIN
      docs/screenshots/local-vm/phone-desktop.png
  47. BIN
      docs/screenshots/local-vm/settings.png
  48. BIN
      docs/screenshots/local-vm/two-desktops.png
  49. 36 0
      docs/shared-computers.md
  50. 31 0
      docs/task-helpers.md
  51. 49 0
      electron-builder.yml
  52. 936 14
      package-lock.json
  53. 14 2
      package.json
  54. 10 0
      playwright.team-tools.config.ts
  55. 44 0
      scripts/build-desktop.mjs
  56. 22 0
      scripts/build-runner.mjs
  57. 11 0
      scripts/copy-worker.mjs
  58. 6 0
      scripts/verify-lifecycle.mjs
  59. 5 0
      scripts/write-build-info.mjs
  60. 2 1
      src/client/api/systemUpdate.ts
  61. 65 0
      src/client/components/app/LocalVmSettingsPanel.vue
  62. 118 0
      src/client/components/app/RunnerSettingsPanel.vue
  63. 5 0
      src/client/components/app/SettingsCenterDialog.vue
  64. 8 0
      src/client/components/app/SystemManagementPanel.vue
  65. 5 0
      src/client/components/app/WorkspaceShell.vue
  66. 3 1
      src/client/components/common/AppIcon.vue
  67. 1 0
      src/client/components/composer/ComposerShell.vue
  68. 131 0
      src/client/components/workspace/ComputerPanel.vue
  69. 68 0
      src/client/components/workspace/LocalVmChatPanel.vue
  70. 32 0
      src/client/components/workspace/LocalVmWorkspace.vue
  71. 33 0
      src/client/components/workspace/TaskPlan.vue
  72. 144 24
      src/client/views/ConversationsView.vue
  73. 303 0
      src/runner/agent.ts
  74. 250 0
      src/runner/computers/container.ts
  75. 22 0
      src/runner/computers/imageCli.ts
  76. 74 0
      src/runner/computers/images.ts
  77. 118 0
      src/runner/computers/localVm.ts
  78. 70 0
      src/runner/computers/maintenance.ts
  79. 34 0
      src/runner/computers/maintenanceCli.ts
  80. 207 0
      src/runner/computers/pool.ts
  81. 32 0
      src/runner/computers/workspaceRecovery.ts
  82. 21 0
      src/runner/config.ts
  83. 53 0
      src/runner/index.ts
  84. 66 0
      src/runner/network/computerProxy.ts
  85. 116 0
      src/runner/network/guest_proxy.py
  86. 37 0
      src/runner/network/publicDestination.ts
  87. 65 0
      src/runner/network/socketBroker.ts
  88. 132 0
      src/runner/worker/control.ts
  89. 320 0
      src/runner/worker/gateway.ts
  90. 207 0
      src/runner/worker/hermes_worker.py
  91. 71 0
      src/runner/worker/process.ts
  92. 33 4
      src/server/app.ts
  93. 13 0
      src/server/buildIdentity.ts
  94. 2 1
      src/server/chatCache.ts
  95. 77 0
      src/server/computerControls.ts
  96. 38 3
      src/server/dashboardSupervisor.ts
  97. 19 4
      src/server/index.ts
  98. 2 0
      src/server/localAuth.ts
  99. 171 0
      src/server/localVm.ts
  100. 3 0
      src/server/realtimeBroker.ts

+ 4 - 0
.dockerignore

@@ -11,6 +11,10 @@
 !index.html
 !public/
 !public/**
+!deploy/
+!deploy/**
+!third-party/
+!third-party/**
 !scripts/
 !scripts/**
 !src/

+ 5 - 0
.gitignore

@@ -15,9 +15,14 @@ docker.env
 hermes-yaoyao-*.tar
 .hermes-yaoyao/
 .codegraph/
+.desktop-build/
+desktop-release/
+desktop/icon.png
 
 # Hermes plugin runtime and Python test caches.
 hermes-plugins/**/__pycache__/
 hermes-plugins/**/*.py[cod]
 hermes-plugins/**/.pytest_cache/
 hermes-plugins/**/data/
+.runner-build/
+build-info.json

+ 3 - 1
Dockerfile

@@ -6,12 +6,14 @@ WORKDIR /app
 
 COPY package.json package-lock.json ./
 COPY release.json ./
-RUN npm ci
+RUN ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm ci
 
 COPY tsconfig.json tsconfig.client.json tsconfig.client.build.json tsconfig.server.json vite.config.ts ./
 COPY index.html ./
 COPY public ./public
 COPY scripts ./scripts
+COPY deploy ./deploy
+COPY third-party ./third-party
 COPY src ./src
 
 RUN npm run build \

+ 36 - 0
bin/desktop-service-sync.mjs

@@ -0,0 +1,36 @@
+import { join, resolve } from 'node:path'
+import { homedir } from 'node:os'
+import { createHash } from 'node:crypto'
+import { mkdirSync } from 'node:fs'
+import { LaunchAgentService, synchronizeDesktop, stopDesktopService } from './lib/service-update.mjs'
+
+// Losing the App's progress pipe must not interrupt a durable service transaction.
+process.stdout.on('error', error => { if (!['EPIPE', 'EIO'].includes(error.code)) process.exitCode = 1 })
+
+let input = ''
+for await (const bytes of process.stdin) { input += bytes; if (input.length > 32768) throw new Error('同步参数过大') }
+try {
+  const options = JSON.parse(input)
+  const home = resolve(options.home), port = Number(options.port)
+  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('本机服务端口无效')
+  mkdirSync(home, { recursive: true, mode: 0o700 })
+  // Fixture mode isolates *all* service/update state, including launchd labels.
+  const fixture = options.fixture === true
+  const releaseRoot = fixture ? join(home, 'updates', 'fixture-releases') : join(homedir(), '.local', 'share', 'hermes-yaoyao')
+  const label = fixture ? `cn.samien.yaoyao.sync-test.${createHash('sha256').update(home).digest('hex').slice(0, 16)}` : 'com.samien.hermes-yaoyao'
+  const driver = new LaunchAgentService({ home, port, releaseRoot, label,
+    plistPath: fixture ? join(home, 'updates', 'fixture-service.plist') : undefined,
+    environment: options.environment,
+  })
+  if(options.action!==undefined&&!['sync','stop'].includes(options.action))throw new Error('本机服务操作无效')
+  const onProgress = message => process.stdout.write(JSON.stringify({ message }) + '\n')
+  const result = options.action==='stop'
+    ? await stopDesktopService({home,driver,onProgress})
+    : await synchronizeDesktop({ home, runtimeRoot: resolve(options.runtimeRoot), releaseRoot, driver, onProgress })
+  const current = result.current
+  process.stdout.write(JSON.stringify({ result: { action: result.action, current: current && {
+    version: current.version, commit: current.commit, buildNumber: current.buildNumber, artifactDigest: current.artifactDigest,
+  } } }) + '\n')
+} catch (error) {
+  process.stdout.write(JSON.stringify({ error: error.message, code: error.code }) + '\n'); process.exitCode = 1
+}

+ 27 - 62
bin/hermes-yaoyao-updater.mjs

@@ -16,6 +16,9 @@ import { dirname, join, relative, resolve, sep } from 'node:path'
 import { fileURLToPath } from 'node:url'
 
 const label = 'com.samien.hermes-yaoyao'
+import { LaunchAgentService, updateMutex, recoverTransition, transitionService } from './lib/service-update.mjs'
+import { assertRollbackCompatible } from './lib/service-data.mjs'
+
 const uid = process.getuid?.() ?? 501
 const domain = `gui/${uid}`
 
@@ -249,76 +252,36 @@ function stageRelease(job, jobPath) {
   return { finalRoot, commit }
 }
 
-async function runUpdate(jobPath, job) {
-  const { plan, target, id } = job
-  if (!target) fail('升级任务缺少目标发布清单')
-  const { finalRoot, commit } = stageRelease(job, jobPath)
+function serviceOptions(jobPath, job) {
+  const home = job.plan.home || resolve(dirname(jobPath), '..')
+  const options = { home, releaseRoot: job.plan.releaseRoot, port: job.plan.port || Number(process.env.HERMES_YAOYAO_PORT || 15300) }
+  return { ...options, driver: new LaunchAgentService(options), onProgress: message => updateJob(jobPath, { message }) }
+}
 
-  const previousCurrentTarget = currentTarget(plan.releaseRoot)
-  let transitioned = false
-  try {
-    updateJob(jobPath, { state: 'installing', message: `正在切换 Web ${target.webVersion}` })
-    stopService()
-    transitioned = true
-    switchCurrent(plan.releaseRoot, finalRoot, id)
-
-    updateJob(jobPath, { state: 'restarting', message: '正在启动新版本服务' })
-    startService(join(plan.releaseRoot, 'current'), plan, true)
-    updateJob(jobPath, { state: 'verifying', message: '正在验证 Web 服务' })
-    await verifyRuntime()
-    writeJSON(join(dirname(jobPath), 'last-success.json'), {
-      jobId: id,
-      previousCurrentTarget,
-      previousServiceRoot: plan.previousServiceRoot,
-      target,
-      finalRoot,
-      commit,
-    })
-    updateJob(jobPath, { state: 'succeeded', message: `已升级 Web ${target.webVersion}` })
-  } catch (error) {
-    const message = error instanceof Error ? error.message : String(error)
-    if (transitioned) {
-      updateJob(jobPath, { state: 'rolling_back', message: '升级失败,正在自动恢复上一版本', error: message })
-      try {
-        stopService()
-        restorePreviousService({
-          plan,
-          previousCurrentTarget,
-          previousServiceRoot: plan.previousServiceRoot,
-          lifecycleRoot: finalRoot,
-          token: `${id}-rollback`,
-        })
-        await verifyRuntime()
-        updateJob(jobPath, { state: 'failed', message: '升级失败,已自动恢复上一版本', error: message })
-      } catch (rollbackError) {
-        const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
-        updateJob(jobPath, { state: 'failed', message: '升级和自动回滚均失败,需要人工处理', error: `${message}; 回滚:${rollbackMessage}` })
-      }
-    } else {
-      updateJob(jobPath, { state: 'failed', message: '升级准备失败,现有服务未被修改', error: message })
-    }
-  }
+async function runUpdate(jobPath, job) {
+  const options = serviceOptions(jobPath, job)
+  await recoverTransition(options)
+  const { finalRoot } = stageRelease(job, jobPath)
+  updateJob(jobPath, { state: 'installing', message: '正在准备切换 Web 服务' })
+  await transitionService({ ...options, finalRoot })
+  updateJob(jobPath, { state: 'succeeded', message: `已升级 Web ${job.target.webVersion}` })
 }
 
 async function runRollback(jobPath, job) {
-  const { plan, id } = job
+  const options = serviceOptions(jobPath, job)
+  await recoverTransition(options)
   const recordPath = join(dirname(jobPath), 'last-success.json')
   if (!existsSync(recordPath)) fail('没有可回滚的上一版本')
   const record = readJSON(recordPath)
-  const lifecycleRoot = currentTarget(plan.releaseRoot)
-  if (!lifecycleRoot) fail('当前发布版本不可用,无法安全执行回滚')
+  // Manual rollback never restores an older snapshot over data written since
+  // the successful upgrade. Schema-changing releases need a separate recovery.
+  assertRollbackCompatible(options.home, record)
+  const finalRoot = record.previousCurrentTarget || record.previousServiceRoot
+  if (!finalRoot || !existsSync(finalRoot)) fail('上一版本的程序目录已不可用')
   updateJob(jobPath, { state: 'rolling_back', message: '正在恢复上一版本' })
-  stopService()
-  restorePreviousService({
-    plan,
-    previousCurrentTarget: record.previousCurrentTarget,
-    previousServiceRoot: record.previousServiceRoot,
-    lifecycleRoot,
-    token: id,
-  })
-  await verifyRuntime()
-  rmSync(recordPath, { force: true })
-  updateJob(jobPath, { state: 'rolled_back', message: '已回滚 Web 服务' })
+  await transitionService({ ...options, finalRoot, rollback: true })
+  rmSync(recordPath)
+  updateJob(jobPath, { state: 'rolled_back', message: '已回滚 Web 服务,保留现有数据' })
 }
 
 async function main() {
@@ -326,6 +289,7 @@ async function main() {
   if (command !== 'run' || flag !== '--job' || !jobPathValue) fail('用法:hermes-yaoyao-updater run --job <path>')
   const jobPath = resolve(jobPathValue)
   const job = readJSON(jobPath)
+  const unlock = updateMutex(job.plan.home || resolve(dirname(jobPath), '..'))
   try {
     if (job.operation === 'update') await runUpdate(jobPath, job)
     else if (job.operation === 'rollback') await runRollback(jobPath, job)
@@ -344,6 +308,7 @@ async function main() {
       try { lockJobID = JSON.parse(raw).jobID } catch { /* legacy lock */ }
       if (lockJobID === job.id) rmSync(lockPath)
     } catch { /* already released */ }
+    unlock()
   }
 }
 

+ 78 - 0
bin/lib/runtime-release.mjs

@@ -0,0 +1,78 @@
+import { createHash } from 'node:crypto'
+import { execFileSync } from 'node:child_process'
+import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'
+import { join, relative, resolve } from 'node:path'
+
+const digest = value => createHash('sha256').update(value).digest('hex')
+export const readJSON = path => JSON.parse(readFileSync(path, 'utf8'))
+export function sourceBuild(root) {
+  try {
+    const git = args => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
+    const ancestors = git(['rev-list', 'HEAD']).split('\n')
+    return { commit: ancestors[0], ancestors, buildNumber: ancestors.length, dirty: Boolean(git(['status', '--porcelain'])) }
+  } catch { return { commit: 'source-archive', ancestors: [], buildNumber: 0, dirty: true } }
+}
+export function readRuntime(root) {
+  const release = readJSON(join(root, 'release.json'))
+  if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(release.webVersion) || release.webVersion !== release.releaseVersion || release.gitTag !== `v${release.webVersion}`)
+    throw new Error('服务发布清单无效')
+  const build = existsSync(join(root, 'build-info.json')) ? readJSON(join(root, 'build-info.json')) : sourceBuild(root)
+  const manifest = existsSync(join(root, 'runtime-manifest.json')) ? readJSON(join(root, 'runtime-manifest.json')) : undefined
+  return { ...build, version: release.webVersion, release, hasBuildIdentity: Number.isSafeInteger(build.buildNumber) && existsSync(join(root, 'build-info.json')), artifactDigest: manifest?.artifactDigest }
+}
+export function compareVersions(a, b) {
+  const parts = value => { const split = value.indexOf('-'); return [value.slice(0, split < 0 ? undefined : split).split('.').map(Number), split < 0 ? undefined : value.slice(split + 1).split('.')] }
+  const [ac, as] = parts(a), [bc, bs] = parts(b)
+  for (let i = 0; i < 3; i++) if (ac[i] !== bc[i]) return ac[i] > bc[i] ? 1 : -1
+  if (!as || !bs) return as ? -1 : bs ? 1 : 0
+  for (let i = 0; i < Math.max(as.length, bs.length); i++) {
+    if (as[i] === bs[i]) continue
+    if (as[i] === undefined || bs[i] === undefined) return as[i] === undefined ? -1 : 1
+    const an = /^\d+$/.test(as[i]), bn = /^\d+$/.test(bs[i])
+    if (an && bn) return Number(as[i]) > Number(bs[i]) ? 1 : -1
+    if (an !== bn) return an ? -1 : 1
+    return as[i] > bs[i] ? 1 : -1
+  }
+  return 0
+}
+/** Commit hashes are identities, never sortable version numbers. Unknown branch
+ * ancestry must not turn an App install into an accidental downgrade. */
+export function syncDecision(target, current) {
+  if (!current) return 'install'
+  const version = compareVersions(target.version, current.version)
+  if (version) return version > 0 ? 'upgrade' : 'newer'
+  if (target.artifactDigest && target.artifactDigest === current.artifactDigest) return 'same'
+  if (target.commit === current.commit && !target.dirty && !current.dirty) return 'same'
+  if (target.ancestors?.includes(current.commit) && target.commit !== current.commit) return 'upgrade'
+  if (current.ancestors?.includes(target.commit) && target.commit !== current.commit) return 'newer'
+  return 'unknown'
+}
+export function runtimeFiles(root, prefix = '') {
+  const files = {}
+  for (const name of readdirSync(join(root, prefix)).sort()) {
+    const path = join(prefix, name), stat = lstatSync(join(root, path))
+    if (path === 'runtime-manifest.json') continue
+    if (stat.isSymbolicLink()) throw new Error('服务包不得包含符号链接')
+    if (stat.isDirectory()) Object.assign(files, runtimeFiles(root, path))
+    else if (stat.isFile()) files[path] = digest(readFileSync(join(root, path)))
+    else throw new Error('服务包包含不支持的文件')
+  }
+  return files
+}
+export function sealRuntime(root) {
+  const files = runtimeFiles(root), artifactDigest = digest(JSON.stringify(files))
+  writeFileSync(join(root, 'runtime-manifest.json'), JSON.stringify({ schemaVersion: 1, artifactDigest, files }, null, 2) + '\n')
+  return artifactDigest
+}
+export function verifyRuntimePackage(root) {
+  const manifest = readJSON(join(root, 'runtime-manifest.json')), files = runtimeFiles(root)
+  if (manifest.schemaVersion !== 1 || digest(JSON.stringify(files)) !== manifest.artifactDigest || JSON.stringify(files) !== JSON.stringify(manifest.files))
+    throw new Error('App 配套服务包校验失败,请重新安装完整 App')
+  for (const file of ['node', 'server.mjs', 'ui/index.html', 'bin/hermes-yaoyao.mjs', 'bin/hermes-yaoyao-updater.mjs', 'build-info.json'])
+    if (!files[file]) throw new Error(`App 配套服务包缺少 ${file}`)
+  return readRuntime(root)
+}
+export function inside(path, root) {
+  const result = relative(realpathSync(root), resolve(path))
+  return result !== '' && result !== '..' && !result.startsWith('../') && !result.startsWith('/')
+}

+ 57 - 0
bin/lib/service-data.mjs

@@ -0,0 +1,57 @@
+import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { createHash } from 'node:crypto'
+import { DatabaseSync } from 'node:sqlite'
+
+// These are process/desktop state, not Web application data. Runner databases
+// have their own owner and must never be restored by a Web update.
+const excluded = name => ['updates', 'desktop', 'runner-state'].includes(name) || name.startsWith('service-instance')
+export function databaseSchema(home) {
+  const result = {}
+  for (const name of readdirSync(home).sort()) {
+    if (excluded(name) || !/\.(?:sqlite3?|db)$/.test(name) || lstatSync(join(home, name)).isSymbolicLink()) continue
+    const db = new DatabaseSync(join(home, name), { readOnly: true })
+    try {
+      result[name] = { schema: db.prepare("SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name").all(), version: db.prepare('PRAGMA user_version').get() }
+    } finally { db.close() }
+  }
+  return createHash('sha256').update(JSON.stringify(result)).digest('hex')
+}
+export function assertRollbackCompatible(home, record) {
+  if (!record.beforeSchema || record.beforeSchema !== record.afterSchema || databaseSchema(home) !== record.afterSchema)
+    throw new Error('数据库结构已变化或缺少兼容性记录,不能直接降级;请使用独立的数据恢复流程')
+}
+export function backupData(home, directory) {
+  mkdirSync(directory, { recursive: true, mode: 0o700 })
+  const names = readdirSync(home).filter(name => !excluded(name))
+  for (const name of names) cpSync(join(home, name), join(directory, name), { recursive: true, dereference: false, verbatimSymlinks: true, preserveTimestamps: true })
+  const schema = databaseSchema(home)
+  writeFileSync(join(directory, '.snapshot.json'), JSON.stringify({ names, schema }), { mode: 0o600 })
+  return schema
+}
+export function restoreData(home, directory) {
+  const { names } = JSON.parse(readFileSync(join(directory, '.snapshot.json'), 'utf8'))
+  if (!Array.isArray(names) || names.some(name => typeof name !== 'string' || name.includes('/') || name === '..' || excluded(name))) throw new Error('数据备份清单无效')
+  for (const name of readdirSync(home)) if (!excluded(name)) rmSync(join(home, name), { recursive: true, force: true })
+  for (const name of names) cpSync(join(directory, name), join(home, name), { recursive: true, dereference: false, verbatimSymlinks: true, preserveTimestamps: true })
+}
+/** Conservative admission for pre-maintenance releases. No cache means an
+ * existing ordinary-chat workload cannot be proven idle. */
+export function legacyDataIdle(home) {
+  const workspace = join(home, 'workspace.sqlite3')
+  if (existsSync(workspace)) {
+    const db = new DatabaseSync(workspace, { readOnly: true })
+    try {
+      if (db.prepare("SELECT 1 FROM workspace_entities WHERE kind IN ('run','turn') AND COALESCE(json_extract(data,'$.status'),'unknown') NOT IN ('complete','failed','interrupted') LIMIT 1").get()) return false
+    } finally { db.close() }
+  }
+  const cache = join(home, 'chat-cache.sqlite3')
+  if (!existsSync(cache)) return !existsSync(workspace)
+  const db = new DatabaseSync(cache, { readOnly: true })
+  try {
+    // Compare event sequence within each session, never across sessions.
+    return !db.prepare(`SELECT 1 FROM chat_events e WHERE e.event_type IN ('message.start','message.delta','approval.request','clarify.request','command:prompt.submit','command:session.steer')
+      AND NOT EXISTS(SELECT 1 FROM chat_events done WHERE done.owner=e.owner AND done.profile=e.profile AND done.session_id=e.session_id
+        AND done.event_type='message.complete' AND done.received_at>=e.received_at) LIMIT 1`).get()
+  } finally { db.close() }
+}

+ 334 - 0
bin/lib/service-update.mjs

@@ -0,0 +1,334 @@
+import { execFileSync } from 'node:child_process'
+import { randomUUID, createHash } from 'node:crypto'
+import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { dirname, join, relative, resolve } from 'node:path'
+import { request as httpRequest } from 'node:http'
+import { request as httpsRequest } from 'node:https'
+import { DatabaseSync } from 'node:sqlite'
+import { readJSON, readRuntime, syncDecision, verifyRuntimePackage } from './runtime-release.mjs'
+import { backupData, databaseSchema, legacyDataIdle, restoreData } from './service-data.mjs'
+
+const sleep = ms => new Promise(done => setTimeout(done, ms))
+export const alive = pid => { try { process.kill(pid, 0); return true } catch (error) { return error.code !== 'ESRCH' } }
+export function writeJSON(path, value) {
+  mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
+  const temporary = `${path}.${randomUUID()}`
+  writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); renameSync(temporary, path)
+}
+export function updateMutex(home) {
+  mkdirSync(join(home, 'updates'), { recursive: true, mode: 0o700 })
+  const db = new DatabaseSync(join(home, 'updates', 'mutex.sqlite3'))
+  chmodSync(join(home, 'updates', 'mutex.sqlite3'), 0o600)
+  try { db.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE') }
+  catch { db.close(); throw new Error('已有系统更新正在执行,请稍后重试') }
+  return () => db.close()
+}
+export function reserveUpdate(home, jobID) {
+  const path = join(home, 'updates', 'active.lock')
+  if (existsSync(path)) {
+    const lock = readJSON(path)
+    if (Number.isSafeInteger(lock.pid) && alive(lock.pid)) throw new Error('已有系统更新正在执行,请稍后重试')
+    // Called only while the common SQLite update mutex is held.
+    rmSync(path)
+  }
+  writeFileSync(path, JSON.stringify({ jobID, pid: process.pid }), { flag: 'wx', mode: 0o600 })
+  return () => { if (existsSync(path) && readJSON(path).jobID === jobID) rmSync(path) }
+}
+export function localRequest(url, token, method = 'GET') {
+  const target = new URL(url)
+  if (!['http:', 'https:'].includes(target.protocol) || !['127.0.0.1', '[::1]', 'localhost'].includes(target.hostname) || target.username || target.password)
+    throw new Error('升级只支持已核对的本机服务')
+  return new Promise((done, reject) => {
+    const req = (target.protocol === 'https:' ? httpsRequest : httpRequest)(target, {
+      method, agent: false, headers: token ? { 'x-yaoyao-desktop-token': token } : {},
+    }, res => {
+      let data = ''
+      res.on('data', bytes => { data += bytes; if (data.length > 128 * 1024) req.destroy(new Error('服务响应过大')) })
+      res.on('end', () => { try { done({ status: res.statusCode, body: JSON.parse(data) }) } catch { reject(new Error('本地服务响应无效')) } })
+    })
+    req.on('error', reject); req.setTimeout(3000, () => req.destroy(new Error('本地服务请求超时'))); req.end()
+  })
+}
+function command(bin, args, input) {
+  try { return execFileSync(bin, args, { encoding: 'utf8', input, timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'] }) }
+  catch { throw new Error(`${bin.split('/').at(-1)} ${args[0]} 未完成`) }
+}
+export function currentRelease(releaseRoot) {
+  const path = join(releaseRoot, 'current')
+  if (!existsSync(path)) return undefined
+  if (!lstatSync(path).isSymbolicLink()) throw new Error('发布目录 current 必须是符号链接')
+  return realpathSync(path)
+}
+export function switchRelease(releaseRoot, target) {
+  mkdirSync(releaseRoot, { recursive: true, mode: 0o700 })
+  releaseRoot = realpathSync(releaseRoot)
+  if (target) target = realpathSync(target)
+  const path = join(releaseRoot, 'current')
+  if (existsSync(path) && !lstatSync(path).isSymbolicLink()) throw new Error('拒绝替换非符号链接发布目录')
+  if (!target) { rmSync(path, { force: true }); return }
+  const temp = join(releaseRoot, `.current-${randomUUID()}`)
+  symlinkSync(relative(releaseRoot, target), temp); renameSync(temp, path)
+}
+export class LaunchAgentService {
+  constructor({ home, port = 15300, releaseRoot, label = 'com.samien.hermes-yaoyao', plistPath, environment = {} }) {
+    this.home = realpathSync(home); this.port = port; this.releaseRoot = resolve(releaseRoot)
+    this.label = label; this.domain = `gui/${process.getuid()}`
+    this.plistPath = plistPath ?? join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`)
+    this.environment = environment
+  }
+  snapshot() {
+    if (!existsSync(this.plistPath)) {
+      // Never install a second service over an unregistered listener or owner.
+      if (this.listeners().size || this.record()) throw new Error('已有服务未注册为此 App 的本机后台服务,暂不接管')
+      return undefined
+    }
+    const plist = JSON.parse(command('/usr/bin/plutil', ['-convert', 'json', '-o', '-', this.plistPath]))
+    const env = plist.EnvironmentVariables ?? {}, root = realpathSync(plist.WorkingDirectory)
+    this.tls = Boolean(env.HERMES_YAOYAO_TLS_CERT)
+    if (plist.Label !== this.label || realpathSync(env.HERMES_YAOYAO_HOME ?? join(homedir(), '.hermes-yaoyao')) !== this.home || Number(env.HERMES_YAOYAO_PORT ?? 15300) !== this.port)
+      throw new Error('已有服务的数据目录或端口与 App 不一致,暂不接管')
+    const args = plist.ProgramArguments
+    if (!Array.isArray(args) || args.length !== 2 || !['server.mjs', 'dist-server/server/index.js'].some(entry => realpathSync(args[1]) === join(root, entry)))
+      throw new Error('已有服务的启动入口无法核对')
+    readRuntime(root)
+    const pid = this.pid(), listeners = this.listeners()
+    if (listeners.size && (listeners.size !== 1 || !listeners.has(pid))) throw new Error('端口由另一个进程占用,暂不接管')
+    if (pid) {
+      const holders = this.holders(join(this.home, 'workspace.sqlite3'))
+      if (!holders.has(pid)) throw new Error('无法确认现有服务的数据归属')
+    }
+    return { plist, root, wasRunning: Boolean(pid) }
+  }
+  pid() {
+    try { return Number(execFileSync('launchctl', ['print', `${this.domain}/${this.label}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).match(/^\s*pid = (\d+)$/m)?.[1]) || undefined }
+    catch { return undefined }
+  }
+  holders(path) {
+    try { return new Set(execFileSync('/usr/sbin/lsof', ['-t', path], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split(/\s+/).map(Number)) }
+    catch { return new Set() }
+  }
+  listeners(established = false) {
+    const pid = established ? this.pid() : undefined
+    if (established && !pid) return new Set()
+    try { return new Set(execFileSync('/usr/sbin/lsof', [...(pid ? ['-a', '-p', String(pid)] : []), '-nP', `-iTCP:${this.port}`, '-sTCP:' + (established ? 'ESTABLISHED' : 'LISTEN'), '-t'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split(/\s+/).map(Number)) }
+    catch { return new Set() }
+  }
+  record() {
+    try {
+      const value = readJSON(join(this.home, 'service-instance.json'))
+      return value.protocol === 1 && value.dataKey === createHash('sha256').update(this.home).digest('hex') && Number.isSafeInteger(value.pid) && alive(value.pid) ? value : undefined
+    } catch { return undefined }
+  }
+  url(record = this.record()) {
+    if (!record?.url) return `${this.tls ? 'https' : 'http'}://127.0.0.1:${this.port}`
+    const url = new URL(record.url)
+    if (Number(url.port) !== this.port) throw new Error('本机服务端口发生变化')
+    if (url.hostname === '0.0.0.0') url.hostname = '127.0.0.1'
+    return url.origin
+  }
+  async quiesce(onProgress, timeout = 60000) {
+    if (!this.pid()) return
+    const deadline = Date.now() + timeout
+    do {
+      const record = this.record()
+      if (record?.maintenanceProtocol === 1) {
+        if (record.pid !== this.pid()) throw new Error('服务进程归属发生变化')
+        const response = await localRequest(`${this.url(record)}/desktop/service/quiesce`, record.token, 'POST')
+        if (response.status === 200 && response.body.quiesced === true) return
+        if (response.status !== 409) throw new Error('服务未接受更新准备请求')
+      } else if (legacyDataIdle(this.home) && !this.listeners(true).size) {
+        return
+      }
+      onProgress('正在等待聊天和后台任务结束后同步 Web…')
+      if (Date.now() >= deadline) break
+      await sleep(1000)
+    } while (Date.now() < deadline)
+    throw Object.assign(new Error('当前仍有任务或旧版客户端连接,已延后同步;任务结束并关闭其他客户端后可重试'), { code: 'service_busy' })
+  }
+  async resume() {
+    const record = this.record()
+    if (record?.maintenanceProtocol === 1) await localRequest(`${this.url(record)}/desktop/service/quiesce`, record.token, 'DELETE').catch(() => {})
+  }
+  async stop() {
+    const pid = this.pid()
+    try { execFileSync('launchctl', ['print', `${this.domain}/${this.label}`], { stdio: 'ignore' }); command('launchctl', ['bootout', `${this.domain}/${this.label}`]) }
+    catch (error) { if (this.pid()) throw error }
+    const deadline = Date.now() + 15000
+    while (pid && alive(pid) && Date.now() < deadline) await sleep(100)
+    if ((pid && alive(pid)) || this.listeners().size || this.holders(join(this.home, 'workspace.sqlite3')).size)
+      throw new Error('后台服务尚未完全停止,操作未完成')
+  }
+  writePlist(plist) {
+    mkdirSync(dirname(this.plistPath), { recursive: true })
+    const xml = command('/usr/bin/plutil', ['-convert', 'xml1', '-o', '-', '--', '-'], JSON.stringify(plist))
+    const temporary = `${this.plistPath}.${randomUUID()}`
+    writeFileSync(temporary, xml, { mode: 0o600 }); renameSync(temporary, this.plistPath)
+  }
+  async boot() {
+    let loaded = false
+    try { execFileSync('launchctl', ['print', `${this.domain}/${this.label}`], { stdio: 'ignore' }); loaded = true } catch { /* Not registered yet. */ }
+    if (loaded) { command('launchctl', ['kickstart', `${this.domain}/${this.label}`]); return }
+    const deadline = Date.now() + 20000
+    for (;;) {
+      try { command('launchctl', ['bootstrap', this.domain, this.plistPath]); return }
+      catch (error) { if (Date.now() >= deadline) throw error; await sleep(500) }
+    }
+  }
+  async start(root, previous) {
+    const stable = join(this.releaseRoot, 'current')
+    const node = existsSync(join(root, 'node')) ? join(root, 'node') : previous?.plist.ProgramArguments[0] ?? process.execPath
+    const plist = previous ? structuredClone(previous.plist) : {
+      Label: this.label, RunAtLoad: true, KeepAlive: true, ThrottleInterval: 10, ProcessType: 'Interactive', Umask: 63,
+      StandardOutPath: join(this.home, 'service.log'), StandardErrorPath: join(this.home, 'service.log'),
+      EnvironmentVariables: { PATH: `${dirname(node)}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`, HERMES_YAOYAO_HOST: '127.0.0.1', ...this.environment },
+    }
+    plist.ProgramArguments = [node, join(stable, existsSync(join(root, 'server.mjs')) ? 'server.mjs' : 'dist-server/server/index.js')]
+    plist.WorkingDirectory = stable
+    Object.assign(plist.EnvironmentVariables, { NODE_ENV: 'production', HERMES_YAOYAO_HOME: this.home, HERMES_YAOYAO_PORT: String(this.port),
+      HERMES_YAOYAO_SERVICE_ROOT: stable, HERMES_YAOYAO_RELEASE_ROOT: this.releaseRoot, HERMES_YAOYAO_DESKTOP: '0',
+      HERMES_YAOYAO_STATIC_DIR: join(stable, existsSync(join(root, 'ui')) ? 'ui' : 'dist') })
+    delete plist.EnvironmentVariables.ELECTRON_RUN_AS_NODE
+    this.writePlist(plist); await this.boot()
+  }
+  async restore(previous) {
+    if (!previous) { rmSync(this.plistPath, { force: true }); return }
+    this.writePlist(previous.plist)
+    if (previous.wasRunning && !this.pid()) await this.boot()
+  }
+  async verify(expected, timeout = 45000) {
+    const deadline = Date.now() + timeout
+    do {
+      try {
+        const record = this.record(), url = this.url(record)
+        if (record?.build && record.pid === this.pid()) {
+          const identity = await localRequest(`${url}/desktop/service`, record.token)
+          const actual = identity.body
+          if (identity.status !== 200 || actual.instanceId !== record.instanceId || actual.pid !== record.pid || actual.dataKey !== record.dataKey) throw new Error('实例不匹配')
+          if (expected && (actual.version !== expected.version || actual.build.commit !== expected.commit || (expected.artifactDigest && actual.build.artifactDigest !== expected.artifactDigest))) throw new Error('运行构建不匹配')
+        } else if (expected?.hasBuildIdentity || expected?.artifactDigest || !this.pid()) throw new Error('新构建尚未就绪')
+        const health = await localRequest(`${url}/healthz`)
+        const status = await localRequest(`${url}/api/status`)
+        if (health.status === 200 && health.body.ok && status.status === 200 && status.body.server_kind === 'yaoyao-web') return
+      } catch { /* Startup and launchd recovery can take a moment. */ }
+      await sleep(300)
+    } while (Date.now() < deadline)
+    throw new Error('新服务的构建、健康或接口核验失败')
+  }
+}
+
+export async function recoverTransition({ home, releaseRoot, driver, onProgress = () => {} }) {
+  const path = join(home, 'updates', 'transition.json')
+  if (!existsSync(path)) return
+  const journal = readJSON(path)
+  if (journal.home !== realpathSync(home) || journal.releaseRoot !== resolve(releaseRoot)) throw new Error('未完成更新的归属不一致')
+  onProgress('正在恢复上一次未完成的更新…')
+  if (journal.phase !== 'restored') {
+    await driver.stop()
+    if (journal.snapshot) restoreData(home, journal.snapshot)
+    switchRelease(releaseRoot, journal.previousCurrent)
+    const success = join(home, 'updates', 'last-success.json')
+    if (journal.previousSuccess) writeJSON(success, journal.previousSuccess)
+    else rmSync(success, { force: true })
+    journal.phase = 'restored'; writeJSON(path, journal)
+  }
+  await driver.restore(journal.previous)
+  if (journal.previous?.wasRunning) await driver.verify(undefined)
+  rmSync(path)
+  await driver.resume()
+}
+export async function transitionService({ home, releaseRoot, driver, finalRoot, onProgress = () => {}, rollback = false, waitForIdleMs }) {
+  const previous = driver.snapshot(), previousCurrent = currentRelease(releaseRoot)
+  const path = join(home, 'updates', 'transition.json'), id = randomUUID()
+  const success = join(home, 'updates', 'last-success.json')
+  const journal = { id, home: realpathSync(home), releaseRoot: resolve(releaseRoot), previous, previousCurrent, finalRoot,
+    previousSuccess: existsSync(success) ? readJSON(success) : undefined }
+  const expected = readRuntime(finalRoot)
+  try {
+    if (previous?.wasRunning) await driver.verify(undefined, 5000)
+    await driver.quiesce(onProgress, waitForIdleMs)
+    onProgress('正在备份数据并切换 Web 服务…')
+    // Persist before stopping; recovery never mistakes a partial backup for a complete snapshot.
+    writeJSON(path, journal)
+    await driver.stop()
+    journal.snapshot = join(home, 'updates', 'backups', id)
+    let beforeSchema
+    try { beforeSchema = backupData(home, journal.snapshot) }
+    catch (error) { rmSync(journal.snapshot, { recursive: true, force: true }); throw error }
+    writeJSON(path, journal)
+    switchRelease(releaseRoot, finalRoot)
+    await driver.start(finalRoot, previous)
+    onProgress('正在核对 Web 构建及服务状态…')
+    await driver.verify(expected)
+    const afterSchema = databaseSchema(home)
+    // The new process rejects application traffic until the durable journal is removed.
+    const result = { previousCurrentTarget: previousCurrent, previousServiceRoot: previous?.root, previous,
+      finalRoot, commit: expected.commit, target: expected.release, snapshot: journal.snapshot, beforeSchema, afterSchema }
+    if (!rollback) writeJSON(join(home, 'updates', 'last-success.json'), result)
+    rmSync(path)
+    await driver.resume()
+    return result
+  } catch (error) {
+    if (existsSync(path)) {
+      try { await recoverTransition({ home, releaseRoot, driver, onProgress }) }
+      catch (recovery) { throw new Error(`${error.message};恢复未完成:${recovery.message}`) }
+    } else await driver.resume()
+    throw error
+  }
+}
+
+export async function synchronizeDesktop({ home, runtimeRoot, releaseRoot, driver, onProgress = () => {} }) {
+  mkdirSync(home, { recursive: true, mode: 0o700 })
+  const unlock = updateMutex(home), id = randomUUID()
+  let release
+  try {
+    release = reserveUpdate(home, id)
+    await recoverTransition({ home, releaseRoot, driver, onProgress })
+    onProgress('正在核对 App 配套 Web 服务…')
+    const target = verifyRuntimePackage(runtimeRoot)
+    const marker = join(home, 'updates', 'desktop-sync.json')
+    const previous = driver.snapshot(), current = previous ? readRuntime(previous.root) : undefined
+    if (existsSync(marker) && readJSON(marker).artifactDigest === target.artifactDigest && previous) {
+      if (!previous.wasRunning) { driver.writePlist(previous.plist); await driver.boot() }
+      await driver.verify(undefined)
+      return { action: 'already-synced', current }
+    }
+    const decision = syncDecision(target, current)
+    if (decision === 'unknown') throw new Error('App 与 Web 的构建先后关系无法确定,已保留当前服务;请使用明确的新发布版本')
+    if (decision === 'same' || decision === 'newer') {
+      if (!previous.wasRunning) { driver.writePlist(previous.plist); await driver.boot() }
+      await driver.verify(undefined)
+      writeJSON(marker, { artifactDigest: target.artifactDigest, action: decision, completedAt: new Date().toISOString() })
+      return { action: decision, current }
+    }
+    const finalRoot = join(releaseRoot, 'releases', `${target.version}-${target.artifactDigest.slice(0, 16)}`)
+    if (!existsSync(finalRoot)) {
+      const staging = join(releaseRoot, 'releases', `.staging-${id}`)
+      mkdirSync(dirname(staging), { recursive: true, mode: 0o700 })
+      try { cpSync(runtimeRoot, staging, { recursive: true }); verifyRuntimePackage(staging); chmodSync(join(staging, 'node'), 0o755); renameSync(staging, finalRoot) }
+      finally { rmSync(staging, { recursive: true, force: true }) }
+    }
+    verifyRuntimePackage(finalRoot)
+    await transitionService({ home, releaseRoot, driver, finalRoot, onProgress, waitForIdleMs: 0 })
+    writeJSON(marker, { artifactDigest: target.artifactDigest, action: decision, completedAt: new Date().toISOString() })
+    return { action: decision, current: target }
+  } finally { release?.(); unlock() }
+}
+
+/** Explicit native Stop command. It never changes the release or user data,
+ * and unloads the verified LaunchAgent so KeepAlive cannot restart it. */
+export async function stopDesktopService({home,driver,onProgress=()=>{}}) {
+  const unlock=updateMutex(home)
+  let release
+  try {
+    release=reserveUpdate(home,randomUUID())
+    if(existsSync(join(home,'updates','transition.json')))throw new Error('系统更新尚未完成,请完成更新后再停止后台服务')
+    const previous=driver.snapshot()
+    if(!previous)return {action:'stopped'}
+    if(previous.wasRunning)await driver.verify(undefined,5000)
+    onProgress('正在停止后台任务与虚拟机…')
+    await driver.stop()
+    onProgress('后台服务已停止')
+    return {action:'stopped'}
+  } finally {release?.();unlock()}
+}

+ 96 - 0
deploy/computer/Dockerfile

@@ -0,0 +1,96 @@
+# Derived from OpenMausBot server/container-computer.ts (Apache-2.0).
+# See third-party/openmausbot. Local changes: Yaoyao image metadata.
+FROM docker.io/trycua/xfce-cua@sha256:274eb636f5cf3fc58f705916ee72b7a701270b3877369d08533a385c5325be9b
+USER root
+RUN set -eux; \
+    arch="$(uname -m)"; \
+    case "$arch" in \
+      x86_64) wheel_url='https://files.pythonhosted.org/packages/fa/d7/a43008a328a40c85e7bc706fc20235b9abedc75e28b413817655153157ff/cua_driver-0.20.0-py3-none-manylinux_2_31_x86_64.whl'; wheel_sha='f60c35696a37f37ac954935e478ae4754f220856d022036625c9400d72185961'; wheel_path='/tmp/cua_driver-0.20.0-py3-none-manylinux_2_31_x86_64.whl'; lib_triplet='x86_64-linux-gnu' ;; \
+      aarch64|arm64) wheel_url='https://files.pythonhosted.org/packages/94/9d/1c1838b69067e83266c3d2aae02d74eef353a43dc8644884ccf03fe7f933/cua_driver-0.20.0-py3-none-manylinux_2_31_aarch64.whl'; wheel_sha='48833bc5e4c60e701fc9eefb57dbac36ec77ef3990f816fbbe85b4e954af2c77'; wheel_path='/tmp/cua_driver-0.20.0-py3-none-manylinux_2_31_aarch64.whl'; lib_triplet='aarch64-linux-gnu' ;; \
+      *) echo "unsupported architecture: $arch" >&2; exit 1 ;; \
+    esac; \
+    for ssl_lib in "/lib/$lib_triplet/libssl.so.3" "/lib/$lib_triplet/libcrypto.so.3"; do \
+      if [ -e "$ssl_lib" ] && [ ! -s "$ssl_lib" ]; then \
+        echo "pinned base image is defective on $arch: $ssl_lib is zero bytes, so curl cannot start — re-pull or replace the base image instead of debugging the wheel download" >&2; \
+        exit 1; \
+      fi; \
+    done; \
+    curl -fsSL "$wheel_url" -o "$wheel_path"; \
+    echo "$wheel_sha  $wheel_path" | sha256sum -c -; \
+    /opt/venv/bin/python -m pip install --no-cache-dir --force-reinstall --no-deps "$wheel_path"; \
+    rm -f "$wheel_path"; \
+    driver_bin="$(find /opt/venv/lib -path '*/cua_driver/bin/cua-driver' -type f -print -quit)"; \
+    test -n "$driver_bin"; \
+    install -D -m 0755 "$driver_bin" /usr/local/libexec/openmausbot/cua-driver; \
+    install -d -o cua -g cua -m 0700 /home/cua/workspace; \
+    test "$(/usr/local/libexec/openmausbot/cua-driver --version)" = "cua-driver 0.20.0"
+# Install before XFCE starts so the panel and window manager see the font too.
+# Noto Sans CJK JP is distributed under the SIL Open Font License 1.1.
+RUN set -eux; \
+    install -d -m 0755 /usr/local/share/fonts; \
+    curl -fsSL 'https://raw.githubusercontent.com/notofonts/noto-cjk/165c01b46ea533872e002e0785ff17e44f6d97d8/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf' -o /usr/local/share/fonts/NotoSansCJKjp-Regular.otf; \
+    echo '68a3fc98800b2a27b371f2fb79991daf3633bd89309d4ffaa6946fd587f375b5  /usr/local/share/fonts/NotoSansCJKjp-Regular.otf' | sha256sum -c -; \
+    chmod 0644 /usr/local/share/fonts/NotoSansCJKjp-Regular.otf; \
+    install -d -m 0755 /usr/local/share/licenses/noto-cjk; \
+    curl -fsSL 'https://raw.githubusercontent.com/notofonts/noto-cjk/165c01b46ea533872e002e0785ff17e44f6d97d8/LICENSE' -o /usr/local/share/licenses/noto-cjk/OFL.txt; \
+    echo '6a73f9541c2de74158c0e7cf6b0a58ef774f5a780bf191f2d7ec9cc53efe2bf2  /usr/local/share/licenses/noto-cjk/OFL.txt' | sha256sum -c -; \
+    fc-cache -f
+RUN printf '%s\n' \
+      '#!/bin/sh' \
+      'set -eu' \
+      'workspace=/home/cua/workspace' \
+      'profiles="$workspace/.browser-profiles"' \
+      'mkdir -p "$profiles/google-chrome" "$profiles/chromium" "$HOME/.config"' \
+      'if ! chmod 0700 "$workspace" "$profiles" "$profiles/google-chrome" "$profiles/chromium" 2>/dev/null; then' \
+      '  for directory in "$workspace" "$profiles" "$profiles/google-chrome" "$profiles/chromium"; do' \
+      '    test -r "$directory" && test -w "$directory" && test -x "$directory"' \
+      '  done' \
+      'fi' \
+      'migrate_profile() {' \
+      '  name="$1"' \
+      '  source="$HOME/.config/$name"' \
+      '  target="$profiles/$name"' \
+      '  if [ -d "$source" ] && [ ! -L "$source" ] && [ -z "$(find "$target" -mindepth 1 -print -quit)" ]; then' \
+      '    cp -a "$source"/. "$target"/' \
+      '  fi' \
+      '  rm -rf "$source"' \
+      '  ln -s "$target" "$source"' \
+      '}' \
+      'migrate_profile google-chrome' \
+      'migrate_profile chromium' \
+      'find "$profiles" \( -name SingletonLock -o -name SingletonSocket -o -name SingletonCookie -o -name .parentlock \) -delete' \
+      > /usr/local/bin/prepare-openmausbot-workspace.sh \
+    && chmod 0755 /usr/local/bin/prepare-openmausbot-workspace.sh
+RUN printf '%s\n' \
+      '#!/bin/sh' \
+      '/usr/local/bin/prepare-openmausbot-workspace.sh' \
+      'attempt=0' \
+      'until DISPLAY=:1 xset q >/dev/null 2>&1; do' \
+      '  attempt=$((attempt + 1))' \
+      '  if [ "$attempt" -ge 45 ]; then echo "X display :1 did not become ready within 45 seconds" >&2; exit 1; fi' \
+      '  sleep 1' \
+      'done' \
+      'exec env CUA_DRIVER_INSTALL_CHANNEL=python_package CUA_DRIVER_RS_TELEMETRY_ENABLED=0 /usr/local/libexec/openmausbot/cua-driver serve --socket /run/user/1000/openmausbot-cua.sock --permission-mode standard' \
+      > /usr/local/bin/start-openmausbot-cua-driver.sh \
+    && chmod 0755 /usr/local/bin/start-openmausbot-cua-driver.sh
+RUN printf '%s\n' \
+      '' \
+      '[program:openmausbot-cua-driver]' \
+      'command=/usr/local/bin/start-openmausbot-cua-driver.sh' \
+      'user=cua' \
+      'environment=HOME="/home/cua",USER="cua",DISPLAY=":1"' \
+      'autorestart=true' \
+      'startsecs=2' \
+      'stdout_logfile=/var/log/supervisor/cua-driver.log' \
+      'stderr_logfile=/var/log/supervisor/cua-driver.error.log' \
+      'priority=30' \
+      >> /etc/supervisor/supervisord.conf
+LABEL com.openmausbot.local-vm="1" \
+      com.openmausbot.cua-driver="0.20.0" \
+      com.openmausbot.cua-base="sha256:274eb636f5cf3fc58f705916ee72b7a701270b3877369d08533a385c5325be9b" \
+      com.openmausbot.image-layer="5"
+
+LABEL cn.samien.yaoyao.computer-image="1"
+COPY prepare-firefox.sh /usr/local/bin/prepare-yaoyao-firefox.sh
+RUN chmod 0755 /usr/local/bin/prepare-yaoyao-firefox.sh \
+    && sed -i '2i /usr/local/bin/prepare-yaoyao-firefox.sh || exit 1' /usr/local/bin/start-openmausbot-cua-driver.sh

+ 11 - 0
deploy/computer/prepare-firefox.sh

@@ -0,0 +1,11 @@
+#!/bin/sh
+set -eu
+profiles=/home/cua/workspace/.browser-profiles/firefox
+mkdir -p "$profiles"
+chmod 0700 "$profiles" 2>/dev/null || test -w "$profiles"
+if [ -d "$HOME/.mozilla" ] && [ ! -L "$HOME/.mozilla" ] && [ -z "$(ls -A "$profiles")" ]; then
+  cp -a "$HOME/.mozilla/." "$profiles/"
+fi
+rm -rf "$HOME/.mozilla"
+ln -s "$profiles" "$HOME/.mozilla"
+find "$profiles" \( -name .parentlock -o -name parent.lock -o -name lock \) -delete

+ 1 - 0
desktop/boot.css

@@ -0,0 +1 @@
+:root{color-scheme:light dark;font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#f7f7f7;color:#202020}body{margin:0;min-height:100vh;display:grid;place-items:center}main{max-width:420px;padding:32px;text-align:center}img{border-radius:18px}h1{font-size:24px;margin:24px 0 12px}p{font-size:15px;line-height:1.7;white-space:pre-line}button{font:inherit;min-height:44px;padding:10px 20px;border:1px solid #aaa;border-radius:10px;background:transparent;color:inherit;cursor:pointer;margin:8px}button:focus-visible{outline:2px solid currentColor;outline-offset:3px}button:disabled{opacity:.5;cursor:default}@media(prefers-color-scheme:dark){:root{background:#151515;color:#eee}button{border-color:#777}}

+ 5 - 0
desktop/boot.html

@@ -0,0 +1,5 @@
+<!doctype html>
+<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'">
+<title>夭夭</title><link rel="stylesheet" href="boot.css"></head>
+<body><main><img src="icon.png" alt="" width="72" height="72"><h1>夭夭</h1><p id="status" role="status" aria-live="polite">正在启动本地服务…</p><div id="actions" hidden><button id="retry">重试</button><button id="logs">查看日志</button></div></main><script src="boot.js"></script></body></html>

+ 19 - 0
desktop/boot.js

@@ -0,0 +1,19 @@
+function display(state) {
+  document.getElementById('status').textContent = state.message
+  document.getElementById('actions').hidden = !['error','stopped','disconnected'].includes(state.phase)
+  document.getElementById('retry').textContent = state.phase === 'disconnected' ? '重新连接' : state.phase === 'stopped' ? '启动后台服务' : '重试'
+  document.getElementById('status').setAttribute('role', state.phase === 'error' ? 'alert' : 'status')
+  document.querySelector('main').setAttribute('aria-busy', String(['starting','restarting','stopping'].includes(state.phase)))
+}
+async function render() {
+  try { display(await window.yaoyaoDesktop.status()) }
+  catch { display({ phase: 'error', message: '无法读取启动状态,请重试或查看服务日志。' }) }
+}
+document.getElementById('retry').onclick = async () => {
+  document.getElementById('retry').disabled = true
+  try { await window.yaoyaoDesktop.retry(); await render() }
+  catch { display({ phase: 'error', message: '启动未完成,请重试或查看服务日志。' }) }
+  finally { document.getElementById('retry').disabled = false }
+}
+document.getElementById('logs').onclick = () => window.yaoyaoDesktop.logs()
+void render();setInterval(() => { void render() }, 500)

+ 41 - 0
desktop/core-lifecycle.test.mjs

@@ -0,0 +1,41 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {mkdtemp,readFile,rm,mkdir} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {createServer} from 'node:net'
+import {spawn} from 'node:child_process'
+import {_electron as electron} from '@playwright/test'
+import {localJSON,DesktopServiceManager} from './service-manager.mjs'
+const root=resolve(import.meta.dirname,'..')
+async function freePort(){const server=createServer();await new Promise(done=>server.listen(0,'127.0.0.1',done));const port=server.address().port;await new Promise(done=>server.close(done));return port}
+async function until(read,accept,timeout=30000){const end=Date.now()+timeout;while(Date.now()<end){try{const value=await read();if(accept(value))return value}catch{}await new Promise(done=>setTimeout(done,100))}throw new Error('等待桌面状态超时')}
+test('packaged core service starts, preserves window-close work, recovers, and stops only its own service',{timeout:60000},async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-app-core-')),port=await freePort(),upstreamPort=await freePort()
+  const upstream=spawn(process.execPath,[join(root,'tests/fixtures/fake-hermes.mjs')],{cwd:root,env:{...process.env,FAKE_HERMES_PORT:String(upstreamPort),FAKE_HERMES_LOCAL_AUTH:'1'},stdio:'ignore'})
+  let app,pid
+  try{
+    await until(()=>localJSON(`http://127.0.0.1:${upstreamPort}/api/status`),Boolean)
+    app=await electron.launch({...(process.env.DESKTOP_TEST_EXECUTABLE?{executablePath:process.env.DESKTOP_TEST_EXECUTABLE}:{}),args:process.env.DESKTOP_TEST_EXECUTABLE?[]:[root],cwd:root,env:{...process.env,HERMES_YAOYAO_DESKTOP_TEST_HOME:home,HERMES_YAOYAO_DESKTOP_PORT:String(port),HERMES_YAOYAO_UPSTREAM:`http://127.0.0.1:${upstreamPort}`},timeout:30000})
+    const page=await app.firstWindow();await page.waitForURL(`http://127.0.0.1:${port}/**`)
+    const initial=JSON.parse(await readFile(join(home,'service-instance.json'),'utf8'));pid=initial.pid
+    const identity=await localJSON(`http://127.0.0.1:${port}/desktop/service`,{'x-yaoyao-desktop-token':initial.token})
+    assert.equal(identity.desktopOwned,true);assert.equal(identity.pid,pid);assert.equal(identity.version,'0.3.32')
+    assert.equal(await page.evaluate(async()=>{try{await window.yaoyaoDesktop.retry();return false}catch{return true}}),true)
+    await mkdir(join(root,'test-results/desktop'),{recursive:true});await page.screenshot({path:join(root,'test-results/desktop/packaged-core.png')})
+    await app.evaluate(({BrowserWindow})=>BrowserWindow.getAllWindows()[0].close())
+    assert.equal(await app.evaluate(({BrowserWindow})=>BrowserWindow.getAllWindows()[0].isVisible()),false)
+    assert.equal((await localJSON(`http://127.0.0.1:${port}/healthz`)).ok,true)
+    await app.evaluate(({app})=>app.emit('activate'))
+    assert.equal(await app.evaluate(({BrowserWindow})=>BrowserWindow.getAllWindows()[0].isVisible()),true)
+    process.kill(pid,'SIGKILL')
+    const replacement=await until(async()=>JSON.parse(await readFile(join(home,'service-instance.json'),'utf8')),value=>value.pid!==initial.pid&&value.url)
+    pid=replacement.pid;assert.notEqual(replacement.instanceId,initial.instanceId)
+    await until(()=>localJSON(`http://127.0.0.1:${port}/healthz`),value=>value.ok)
+    const attached=new DesktopServiceManager({home,port,fork:()=>{throw new Error('必须复用已有服务')}})
+    assert.equal((await attached.start()).external,true);await attached.stop()
+    assert.equal((await localJSON(`http://127.0.0.1:${port}/healthz`)).ok,true)
+    await app.close();app=undefined
+    await until(()=>{try{process.kill(pid,0);return false}catch{return true}},Boolean,10000)
+  }finally{await app?.close().catch(()=>{});if(pid)try{process.kill(pid,'SIGTERM')}catch{}upstream.kill('SIGTERM');await rm(home,{recursive:true,force:true})}
+})

+ 23 - 0
desktop/credentials.mjs

@@ -0,0 +1,23 @@
+import {createHash} from 'node:crypto'
+import {execFile} from 'node:child_process'
+const prefix=Buffer.from('YAOYAO-RUNNER-KEYCHAIN-1\n')
+export class DesktopCredentials {
+  constructor({home,helper,legacyDecrypt}){this.account=createHash('sha256').update(home).digest('hex');this.helper=helper;this.legacyDecrypt=legacyDecrypt}
+  async invoke(operation,input){
+    if(input.length>1048576)throw new Error('执行节点配置超过加密大小上限')
+    return new Promise((resolve,reject)=>{
+      const child=execFile(this.helper,[operation,this.account],{encoding:'buffer',timeout:30000,killSignal:'SIGKILL',maxBuffer:1048576},(error,stdout)=>{
+        if(error)reject(new Error('无法访问当前数据目录的系统加密密钥,请完成钥匙串授权后重试'))
+        else resolve(stdout)
+      })
+      child.stdin.on('error',()=>{});child.stdin.end(input)
+    })
+  }
+  async encrypt(value){return Buffer.concat([prefix,await this.invoke('encrypt',Buffer.from(value))])}
+  async decrypt(value){
+    if(value.subarray(0,prefix.length).equals(prefix))return (await this.invoke('decrypt',value.subarray(prefix.length))).toString('utf8')
+    // Existing safeStorage files remain readable after the user grants normal OS access.
+    let timer
+    try{return await Promise.race([this.legacyDecrypt(value),new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('旧配置等待钥匙串授权,请完成系统授权后重试')),30000)})])}finally{clearTimeout(timer)}
+  }
+}

+ 22 - 0
desktop/credentials.test.mjs

@@ -0,0 +1,22 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {mkdtemp,realpath,rm} from 'node:fs/promises'
+import {join,resolve} from 'node:path'
+import {tmpdir} from 'node:os'
+import {execFile} from 'node:child_process'
+import {promisify} from 'node:util'
+import {DesktopCredentials} from './credentials.mjs'
+test('native Keychain encryption is scoped to a directory and authenticates ciphertext',{timeout:30000},async()=>{
+  const home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-keychain-')))
+  const credentials=new DesktopCredentials({home,helper:resolve('.desktop-build/keychain-helper-dev'),legacyDecrypt:async()=>{throw new Error('legacy')}})
+  try{
+    const value='{"token":"fixture-private-machine-token"}',encrypted=await credentials.encrypt(value)
+    assert.equal(encrypted.includes('fixture-private-machine-token'),false)
+    assert.equal(await credentials.decrypt(encrypted),value)
+    const changed=Buffer.from(encrypted);changed[changed.length-1]^=1
+    await assert.rejects(credentials.decrypt(changed))
+    const other=new DesktopCredentials({home:home+'-other',helper:credentials.helper,legacyDecrypt:credentials.legacyDecrypt})
+    await assert.rejects(other.decrypt(encrypted))
+    await assert.rejects(promisify(execFile)(resolve('.desktop-build/keychain-helper'),['decrypt',credentials.account]),/只允许所属 App/)
+  }finally{await promisify(execFile)('/usr/bin/security',['delete-generic-password','-s','cn.samien.yaoyao.runner-key.v1','-a',credentials.account]).catch(()=>{});await rm(home,{recursive:true,force:true})}
+})

+ 65 - 0
desktop/keychain-helper.swift

@@ -0,0 +1,65 @@
+import Foundation
+import Security
+import CryptoKit
+import Darwin
+
+// This helper's signed identity owns each Keychain item. Plaintext travels only
+// on private stdin/stdout, never in command arguments or logs.
+func fail(_ message: String) -> Never {
+    FileHandle.standardError.write(Data((message + "\n").utf8)); exit(1)
+}
+#if !DEVELOPMENT_HELPER
+// The distributed helper accepts only its containing, validly signed App with
+// the same signing team. Copying the helper or invoking it from a shell fails.
+@_silgen_name("proc_pidpath")
+func processPath(_ pid: Int32, _ buffer: UnsafeMutableRawPointer, _ size: UInt32) -> Int32
+var parentPath = [CChar](repeating: 0, count: 4096)
+let pathLength = parentPath.withUnsafeMutableBytes { processPath(getppid(), $0.baseAddress!, UInt32($0.count)) }
+guard pathLength > 0 else { fail("无法核对 App 身份") }
+let parentURL = URL(fileURLWithPath: String(cString: parentPath)).resolvingSymlinksInPath()
+let expected = parentURL.deletingLastPathComponent().appendingPathComponent("../Resources/runtime/keychain-helper").standardizedFileURL.resolvingSymlinksInPath()
+guard expected.path == URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath().path else { fail("加密助手只允许所属 App 调用") }
+var parentCode: SecCode?, ownCode: SecCode?
+guard SecCodeCopyGuestWithAttributes(nil, [kSecGuestAttributePid as String: getppid()] as CFDictionary, [], &parentCode) == errSecSuccess,
+      SecCodeCopySelf([], &ownCode) == errSecSuccess, let parentCode, let ownCode,
+      SecCodeCheckValidity(parentCode, [], nil) == errSecSuccess else { fail("App 签名无效") }
+func signingInfo(_ code: SecCode) -> [String: Any] {
+    var info: CFDictionary?
+    var staticCode: SecStaticCode?
+    guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, let staticCode else { fail("无法读取签名身份") }
+    guard SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &info) == errSecSuccess else { fail("无法核对签名身份") }
+    return info as? [String: Any] ?? [:]
+}
+let parentInfo = signingInfo(parentCode), ownInfo = signingInfo(ownCode)
+guard let team = ownInfo[kSecCodeInfoTeamIdentifier as String] as? String,
+      parentInfo[kSecCodeInfoTeamIdentifier as String] as? String == team,
+      parentInfo[kSecCodeInfoIdentifier as String] as? String == "cn.samien.yaoyao.desktop" else { fail("App 签名身份不匹配") }
+#endif
+guard CommandLine.arguments.count == 3 else { fail("钥匙串请求格式无效") }
+let operation = CommandLine.arguments[1], account = CommandLine.arguments[2]
+guard ["encrypt", "decrypt"].contains(operation), account.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil else { fail("钥匙串请求格式无效") }
+let service = "cn.samien.yaoyao.runner-key.v1"
+let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account]
+func loadKey(create: Bool) -> Data {
+    var result: CFTypeRef?
+    let status = SecItemCopyMatching(query.merging([kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne]) { _, next in next } as CFDictionary, &result)
+    if status == errSecSuccess, let data = result as? Data, data.count == 32 { return data }
+    guard status == errSecItemNotFound, create else { fail("无法读取当前数据目录的加密密钥,请检查系统钥匙串") }
+    var key = Data(count: 32)
+    guard key.withUnsafeMutableBytes({ SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!) }) == errSecSuccess else { fail("无法创建加密密钥") }
+    let added = SecItemAdd(query.merging([kSecValueData as String: key, kSecAttrLabel as String: "夭夭 Runner 配置密钥"]) { _, next in next } as CFDictionary, nil)
+    if added == errSecDuplicateItem { return loadKey(create: false) }
+    guard added == errSecSuccess else { fail("无法保存加密密钥,请检查系统钥匙串") }
+    return key
+}
+let input = FileHandle.standardInput.readDataToEndOfFile()
+guard input.count <= 1_048_576 else { fail("配置超过加密大小上限") }
+do {
+    let key = SymmetricKey(data: loadKey(create: operation == "encrypt"))
+    let output: Data
+    if operation == "encrypt" {
+        guard let combined = try AES.GCM.seal(input, using: key).combined else { fail("配置加密失败") }
+        output = combined
+    } else { output = try AES.GCM.open(AES.GCM.SealedBox(combined: input), using: key) }
+    FileHandle.standardOutput.write(output)
+} catch { fail("配置加解密失败,原配置保留") }

+ 121 - 0
desktop/lifecycle.test.mjs

@@ -0,0 +1,121 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, readFile, writeFile, stat, rm, mkdir, realpath } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { createServer } from 'node:net'
+import { spawn,execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { createHash } from 'node:crypto'
+import { _electron as electron } from '@playwright/test'
+import { localJSON, DesktopServiceManager } from './service-manager.mjs'
+
+const root = resolve(import.meta.dirname, '..')
+async function freePort() {
+  const server = createServer(); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
+  const port = server.address().port; await new Promise(resolve => server.close(resolve));return port
+}
+async function until(read, accept, timeout = 30000) {
+  const end = Date.now() + timeout;let value
+  while (Date.now() < end) {
+    try { value = await read(); if (accept(value)) return value } catch {}
+    await new Promise(resolve => setTimeout(resolve, 100))
+  }
+  throw new Error('等待验收状态超时')
+}
+
+test('real desktop launches its bundled service, survives window close and recovers its own crashed child', { timeout: 90000 }, async () => {
+  const home = await mkdtemp(join(tmpdir(), 'yaoyao-desktop-e2e-'))
+  const port = await freePort(), upstreamPort = await freePort()
+  const upstream = spawn(process.execPath, [join(root, 'tests/fixtures/fake-hermes.mjs')], {
+    cwd: root, env: { ...process.env, FAKE_HERMES_PORT: String(upstreamPort), FAKE_HERMES_LOCAL_AUTH: '1' }, stdio: 'ignore',
+  })
+  let desktop
+  let servicePID, runnerPID, runnerID
+  try {
+    await until(() => localJSON(`http://127.0.0.1:${upstreamPort}/api/status`), Boolean)
+    desktop = await electron.launch({ ...(process.env.DESKTOP_TEST_EXECUTABLE ? { executablePath: process.env.DESKTOP_TEST_EXECUTABLE } : {}),
+      args: process.env.DESKTOP_TEST_EXECUTABLE ? [] : [root], cwd: root, env: { ...process.env,
+      HERMES_YAOYAO_DESKTOP_TEST_HOME: home, HERMES_YAOYAO_DESKTOP_PORT: String(port),
+      HERMES_YAOYAO_UPSTREAM: `http://127.0.0.1:${upstreamPort}` }, timeout: 30000 })
+    desktop.process().stderr.on('data', b => { if (process.env.DESKTOP_TEST_LOGS) process.stderr.write(b) })
+    const page = await desktop.firstWindow()
+    await page.waitForURL(`http://127.0.0.1:${port}/**`, { timeout: 30000 })
+    const owner = await until(async () => JSON.parse(await readFile(join(home, 'service-instance.json'), 'utf8')), r => r.url)
+    servicePID = owner.pid
+    const identity = await localJSON(`http://127.0.0.1:${port}/desktop/service`, { 'x-yaoyao-desktop-token': owner.token })
+    assert.equal(identity.pid, owner.pid);assert.equal(identity.desktopOwned, true)
+    assert.equal(identity.token, undefined)
+    assert.ok((await localJSON(`http://127.0.0.1:${port}/healthz`)).ok)
+    const denied = await page.evaluate(async () => {
+      try { await window.yaoyaoDesktop.retry();return false } catch { return true }
+    })
+    assert.equal(denied, true, 'the web app must not have the boot screen native control privilege')
+    const registration=await page.evaluate(async()=>{
+      const bootstrap=await (await fetch('/api/app/bootstrap')).json()
+      const headers={'Content-Type':'application/json','X-CSRF-Token':bootstrap.csrfToken}
+      const setup=await fetch('/api/app/setup',{method:'POST',headers,body:JSON.stringify({username:'desktop-fixture',password:'desktop-fixture-password'})})
+      if(!setup.ok)throw new Error('fixture setup failed')
+      headers['X-CSRF-Token']=(await (await fetch('/api/app/bootstrap')).json()).csrfToken
+      const response=await fetch('/api/app/admin/runners',{method:'POST',headers,body:JSON.stringify({name:'桌面验收节点',allowedProfiles:['default']})})
+      if(!response.ok)throw new Error(`fixture registration failed HTTP ${response.status}: ${(await response.json()).code}`)
+      return response.json()
+    })
+    runnerID=registration.runner.id
+    const configuration={protocol:1,runnerId:runnerID,token:registration.token,serverURL:`http://127.0.0.1:${port}`,hermesURL:`http://127.0.0.1:${upstreamPort}`,allowedProfiles:['default'],artifactRoots:[]}
+    const imported=join(home,'downloaded-runner.json');await writeFile(imported,JSON.stringify(configuration),{mode:0o600})
+    await desktop.evaluate(({dialog,Menu},path)=>{
+      dialog.showOpenDialog=async()=>({canceled:false,filePaths:[path]})
+      Menu.getApplicationMenu().getMenuItemById('runner-import').click()
+    },imported)
+    await until(()=>page.evaluate(async()=>{const data=await (await fetch('/api/app/admin/runners')).json();return data.runners?.some(r=>r.online)}),Boolean)
+    const encrypted=await readFile(join(home,'desktop-runner.enc'))
+    assert.equal(encrypted.includes(registration.token),false)
+    assert.equal((await stat(join(home,'desktop-runner.enc'))).mode&0o077,0)
+    runnerPID=JSON.parse(await readFile(join(home,'runner-state',runnerID,'service-instance.json'),'utf8')).pid
+    await rm(imported)
+
+    await desktop.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].close())
+    assert.equal(await desktop.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isVisible()), false)
+    assert.ok((await localJSON(`http://127.0.0.1:${port}/healthz`)).ok)
+    await desktop.evaluate(({ app }) => app.emit('activate'))
+    assert.equal(await desktop.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isVisible()), true)
+    await mkdir(join(root, 'test-results/desktop'), { recursive: true })
+    await page.screenshot({ path: join(root, 'test-results/desktop/first-launch.png') })
+    process.kill(owner.pid, 'SIGKILL')
+    const replacement = await until(async () => JSON.parse(await readFile(join(home, 'service-instance.json'), 'utf8')), r => r.pid !== owner.pid && r.url)
+    servicePID = replacement.pid
+    await until(() => localJSON(`http://127.0.0.1:${port}/healthz`), r => r.ok)
+    assert.notEqual(replacement.instanceId, owner.instanceId)
+    await until(()=>page.evaluate(async()=>{const data=await (await fetch('/api/app/admin/runners')).json();return data.runners?.some(r=>r.online)}),Boolean)
+    const updateStatus=await page.evaluate(async()=>await (await fetch('/api/app/system/update/status')).json())
+    assert.equal(updateStatus.installationMode,'desktop');assert.equal(updateStatus.supported,false)
+    assert.equal(await desktop.evaluate(({Menu})=>Menu.getApplicationMenu().getMenuItemById('desktop-background').checked),false)
+    await desktop.evaluate(async({Menu})=>{const item=Menu.getApplicationMenu().getMenuItemById('desktop-background');await item.click(item)})
+    assert.deepEqual(await until(async()=>JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')),value=>value.backgroundAtLogin===true,5000),{backgroundAtLogin:true})
+    const external = new DesktopServiceManager({ home, port, fork: () => { throw new Error('must not fork a second service') } })
+    assert.equal((await external.start()).external, true)
+    await external.stop()
+    assert.ok((await localJSON(`http://127.0.0.1:${port}/healthz`)).ok, 'a connected client must not stop the owned service')
+    await desktop.close();desktop = null
+    await until(async () => { try { process.kill(replacement.pid, 0);return false } catch { return true } }, Boolean, 10000)
+    await until(async()=>{try{process.kill(runnerPID,0);return false}catch{return true}},Boolean,10000)
+    desktop=await electron.launch({...(process.env.DESKTOP_TEST_EXECUTABLE?{executablePath:process.env.DESKTOP_TEST_EXECUTABLE}:{}),args:process.env.DESKTOP_TEST_EXECUTABLE?[]:[root],cwd:root,env:{...process.env,HERMES_YAOYAO_DESKTOP_TEST_HOME:home,HERMES_YAOYAO_DESKTOP_PORT:String(port),HERMES_YAOYAO_UPSTREAM:`http://127.0.0.1:${upstreamPort}`}})
+    const reopened=await desktop.firstWindow();await reopened.waitForURL(`http://127.0.0.1:${port}/**`)
+    await until(()=>reopened.evaluate(async()=>{const data=await (await fetch('/api/app/admin/runners')).json();return data.runners?.some(r=>r.online)}),Boolean)
+    assert.equal(await desktop.evaluate(({Menu})=>Menu.getApplicationMenu().getMenuItemById('desktop-background').checked),true)
+    assert.equal(await desktop.evaluate(({BrowserWindow})=>BrowserWindow.getAllWindows()[0].isVisible()),true,'manual reopening still shows the window')
+    servicePID=JSON.parse(await readFile(join(home,'service-instance.json'),'utf8')).pid
+    runnerPID=JSON.parse(await readFile(join(home,'runner-state',runnerID,'service-instance.json'),'utf8')).pid
+    await desktop.close();desktop=null
+    await until(async()=>{try{process.kill(runnerPID,0);return false}catch{return true}},Boolean,10000)
+  } finally {
+    await desktop?.close().catch(() => {})
+    if(runnerPID){try{process.kill(runnerPID,'SIGTERM')}catch{}}
+    if (servicePID) { try { process.kill(servicePID, 'SIGTERM') } catch {} }
+    upstream.kill('SIGTERM')
+    const account=createHash('sha256').update(await realpath(home)).digest('hex')
+    await promisify(execFile)('/usr/bin/security',['delete-generic-password','-s','cn.samien.yaoyao.runner-key.v1','-a',account]).catch(()=>{})
+    await rm(home, { recursive: true, force: true })
+  }
+})

+ 226 - 0
desktop/main.mjs

@@ -0,0 +1,226 @@
+import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences, dialog, safeStorage } from 'electron'
+import { appendFileSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
+import { join, dirname } from 'node:path'
+import { homedir } from 'node:os'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { DesktopServiceManager } from './service-manager.mjs'
+import { DesktopRunnerManager } from './runner-manager.mjs'
+import { DesktopPreferences } from './preferences.mjs'
+import { DesktopCredentials } from './credentials.mjs'
+import { synchronizeLocalService, stopLocalService } from './service-sync.mjs'
+
+const desktopRoot = dirname(fileURLToPath(import.meta.url))
+const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
+// Fixture identity also isolates Chromium cookies, window state and the app lock.
+if (fixtureHome) app.setPath('userData', join(fixtureHome, 'desktop'))
+app.setName('夭夭')
+if (!app.requestSingleInstanceLock()) { app.quit() }
+else {
+  let window, tray, manager, runnerManager, quitting = false, closing = false, timer
+  const bootURL = pathToFileURL(join(desktopRoot, 'boot.html')).href
+  const root = app.isPackaged ? join(process.resourcesPath, 'runtime') : join(app.getAppPath(), '.desktop-build')
+  const logRoot = join(app.getPath('logs'), fixtureHome ? 'verification' : 'service')
+  mkdirSync(logRoot, { recursive: true })
+  const logFile = join(logRoot, 'server.log')
+  const log = text => appendFileSync(logFile, `${new Date().toISOString()} ${text.trim()}\n`, { mode: 0o600 })
+  const home = fixtureHome || process.env.HERMES_YAOYAO_HOME || join(homedir(), '.hermes-yaoyao')
+  const preferences = new DesktopPreferences(home)
+  let startHidden = false
+  const port = Number(process.env.HERMES_YAOYAO_DESKTOP_PORT || 15300)
+  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('桌面服务端口无效')
+
+  const trustedBoot = event => event.sender === window?.webContents && event.senderFrame?.url === bootURL
+  ipcMain.handle('desktop:status', event => {
+    if (!trustedBoot(event)) throw new Error('不允许此页面访问桌面服务')
+    return { phase: manager?.state.phase || 'starting', message: manager?.state.message || '正在启动…' }
+  })
+  ipcMain.handle('desktop:retry', async event => {
+    if (!trustedBoot(event)) throw new Error('不允许此页面启动桌面服务')
+    manager.restarts = []
+    try { await manager.start(); return true } catch { return false }
+  })
+  ipcMain.handle('desktop:logs', event => {
+    if (!trustedBoot(event)) throw new Error('不允许此页面读取日志')
+    shell.showItemInFolder(logFile)
+  })
+
+  function show() {
+    if (closing || quitting) return
+    if (!window || window.isDestroyed()) createWindow()
+    window.show(); window.focus()
+    if (manager) void manager.reconnect().then(() => stateChanged(manager.state)).catch(error => log(error.message))
+  }
+  function safeExternal(url) {
+    try { const parsed = new URL(url); if (['https:', 'http:', 'mailto:'].includes(parsed.protocol)) void shell.openExternal(url) }
+    catch { /* No arbitrary file, shell or custom URL dispatch. */ }
+  }
+  function createWindow() {
+    window = new BrowserWindow({ width: 1280, height: 860, minWidth: 720, minHeight: 560, show: false,
+      title: '夭夭', backgroundColor: '#f7f7f7',
+      webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
+        webSecurity: true, preload: join(desktopRoot, 'preload.cjs') } })
+    window.webContents.setWindowOpenHandler(({ url }) => {
+      try {
+        if (manager?.state.url && new URL(url).origin === manager.state.url) void window.loadURL(url)
+        else safeExternal(url)
+      } catch { /* Invalid navigation is denied. */ }
+      return { action: 'deny' }
+    })
+    const guardNavigation = (event, url) => {
+      if (url === bootURL || (manager?.state.url && new URL(url).origin === manager.state.url)) return
+      event.preventDefault(); safeExternal(url)
+    }
+    window.webContents.on('will-navigate', guardNavigation)
+    window.webContents.on('will-redirect', guardNavigation)
+    window.webContents.on('will-attach-webview', event => event.preventDefault())
+    const trustedService = (contents, url) => {
+      try { return contents === window?.webContents && !!manager?.state.url && new URL(url).origin === manager.state.url }
+      catch { return false }
+    }
+    window.webContents.session.setPermissionCheckHandler((contents, permission, origin) =>
+      trustedService(contents, origin) && ['media', 'notifications', 'clipboard-sanitized-write'].includes(permission))
+    window.webContents.session.setPermissionRequestHandler((contents, permission, callback, details) => {
+      if (!trustedService(contents, details.requestingUrl)) { callback(false); return }
+      if (permission === 'media' && details.mediaTypes?.length && details.mediaTypes.every(type => type === 'audio')) {
+        void systemPreferences.askForMediaAccess('microphone').then(callback, () => callback(false))
+      } else callback(['notifications', 'clipboard-sanitized-write', 'fullscreen'].includes(permission))
+    })
+    window.on('close', event => { if (!quitting) { event.preventDefault(); window.hide() } })
+    window.once('ready-to-show', () => { if (!startHidden) window.show() })
+    if (manager?.state.phase === 'ready') void window.loadURL(manager.state.url)
+    else void window.loadFile(join(desktopRoot, 'boot.html'))
+  }
+  function stateChanged(state) {
+    log(`${state.phase}: ${state.message}`)
+    // Quit owns the window from this point on. A detached connection is not a
+    // stopped server and must never navigate the closing window to boot.html.
+    if (closing || quitting) return
+    const serviceStatus=Menu.getApplicationMenu()?.getMenuItemById('desktop-service-status')
+    if(serviceStatus)serviceStatus.label=state.phase==='ready'?'后台服务运行中':state.message
+    const localVmMenu=Menu.getApplicationMenu()?.getMenuItemById('desktop-local-vm')
+    if(localVmMenu)localVmMenu.enabled=state.phase==='ready'
+    const notice = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-notice')
+    if (notice) notice.visible = Boolean(state.updateNotice)
+    const retrySync = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-retry')
+    if (retrySync) retrySync.enabled = state.phase === 'ready' && Boolean(manager?.options.synchronize)
+    tray?.setToolTip(`夭夭 · ${state.message}`)
+    if (!window || window.isDestroyed()) return
+    if (state.phase === 'ready') {
+      const current = window.webContents.getURL()
+      if (!current.startsWith(`${state.url}/`) && current !== state.url) void window.loadURL(state.url)
+    } else if (window.webContents.getURL() !== bootURL) void window.loadFile(join(desktopRoot, 'boot.html'))
+  }
+  app.on('second-instance', show)
+  app.on('activate', show)
+  app.on('window-all-closed', () => {})
+  function requestQuit(stopBackground = false) {
+    if (closing || quitting) return
+    closing = true; clearInterval(timer)
+    window?.hide()
+    // Native Cmd+Q can enter before-quit from Cocoa's termination callback.
+    // Both cleanup and the final quit must run after that callback unwinds.
+    setImmediate(() => { void (async()=>{
+      try {
+        await runnerManager?.stop()
+        if (stopBackground) await manager?.stopBackground()
+        else await manager?.stop()
+        quitting = true
+        setImmediate(() => app.quit())
+      } catch(error) {
+        log(error.message)
+        window?.show()
+        const choice=await dialog.showMessageBox(window,{type:'warning',message:stopBackground?'后台服务停止未完成':'App 退出清理未完成',detail:error.message,buttons:['返回应用','仅退出 App'],defaultId:0,cancelId:0})
+        if(choice.response===1){quitting=true;setImmediate(()=>app.quit())}
+        else {
+          closing=false
+          await manager?.reconnect().catch(error=>log(error.message))
+          await runnerManager?.start().catch(error=>log(error.message))
+          stateChanged(manager.state)
+          timer=setInterval(()=>{void manager.check()},10000);timer.unref()
+          show()
+        }
+      }
+    })() })
+  }
+  app.on('before-quit', event => {
+    if (quitting) return
+    event.preventDefault()
+    requestQuit()
+  })
+  app.whenReady().then(async () => {
+    await preferences.load()
+    startHidden = preferences.value.backgroundAtLogin && app.getLoginItemSettings().wasOpenedAtLogin
+    const packageVersion = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).webVersion
+    const buildInfo=JSON.parse(readFileSync(join(root,'build-info.json'),'utf8'))
+    app.setAboutPanelOptions({applicationName:'夭夭',applicationVersion:packageVersion,version:`${String(buildInfo.commit).slice(0,12)}${buildInfo.dirty?' · 工作区快照':''}`})
+    manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
+      stopBackground: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
+        ? onProgress => stopLocalService({home,port,root,fixture:Boolean(fixtureHome),onProgress}) : undefined,
+      synchronize: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
+        ? onProgress => synchronizeLocalService({ home, port, root, fixture: Boolean(fixtureHome), onProgress,
+          environment: { HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119', HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1' } })
+        : undefined,
+      fork: ({ home, port }) => {
+        const env = { ...process.env, NODE_ENV: 'production', NODE_USE_ENV_PROXY: '0',
+          PATH: [join(homedir(), '.local/bin'), '/opt/homebrew/bin', '/usr/local/bin', process.env.PATH || '/usr/bin:/bin'].join(':'),
+          HERMES_YAOYAO_DESKTOP: '1', HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_HOST: '127.0.0.1',
+          HERMES_YAOYAO_PORT: String(port), HERMES_YAOYAO_STATIC_DIR: join(root, 'ui'),
+          HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1',
+          HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119' }
+        delete env.ELECTRON_RUN_AS_NODE
+        delete env.HERMES_YAOYAO_TLS_CERT; delete env.HERMES_YAOYAO_TLS_KEY
+        return utilityProcess.fork(join(root, 'server.mjs'), [], { cwd: root, env, stdio: 'pipe' })
+      },
+    })
+    const { parseRunnerConfiguration } = await import(pathToFileURL(join(root,'runner-config.mjs')).href)
+    mkdirSync(home,{recursive:true,mode:0o700})
+    const credentials=new DesktopCredentials({home:realpathSync(home),helper:join(root,app.isPackaged?'keychain-helper':'keychain-helper-dev'),legacyDecrypt:async bytes=>(await safeStorage.decryptStringAsync(bytes)).result})
+    runnerManager = new DesktopRunnerManager({home,validate:parseRunnerConfiguration,
+      encrypt:value=>credentials.encrypt(value),
+      decrypt:bytes=>credentials.decrypt(bytes),
+      onState:message=>{log(`Runner: ${message}`);const item=Menu.getApplicationMenu()?.getMenuItemById('runner-status');if(item)item.label=message},
+      fork:()=>utilityProcess.fork(join(root,'runner.mjs'),['--desktop-ipc'],{cwd:root,stdio:'pipe',env:{HOME:homedir(),PATH:[join(homedir(),'.local/bin'),join(homedir(),'.orbstack/bin'),'/opt/homebrew/bin','/usr/local/bin',process.env.PATH||'/usr/bin:/bin'].join(':'),NODE_ENV:'production',NODE_USE_ENV_PROXY:'0'}}),
+    })
+    async function runnerAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'执行节点操作未完成',detail:error.message})}}
+    async function importRunner(){
+      const selection=await dialog.showOpenDialog(window,{title:'导入执行节点配置',properties:['openFile'],filters:[{name:'Runner 配置',extensions:['json']}]})
+      if(!selection.canceled&&selection.filePaths[0]&&!closing)await runnerAction(()=>runnerManager.importFile(selection.filePaths[0]))
+    }
+    Menu.setApplicationMenu(Menu.buildFromTemplate([
+      { label: '夭夭', submenu: [{ role: 'about', label: '关于夭夭' }, { type: 'separator' },
+        { id:'desktop-service-status',label:'正在连接后台服务…',enabled:false },
+        { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { if (manager.state.url) safeExternal(manager.state.url) } },
+        { id:'desktop-local-vm',label:'本地虚拟机设置…',enabled:false,click:()=>{if(manager.state.phase==='ready'){show();void window.webContents.executeJavaScript("window.dispatchEvent(new CustomEvent('yaoyao:local-vm-settings'))")}} },
+        { label: '查看服务日志', click: () => shell.showItemInFolder(logFile) },
+        { id:'desktop-sync-notice', label:'Web 同步待完成:服务忙碌', enabled:false, visible:false },
+        { id:'desktop-sync-retry', label:'重试同步本机 Web…', enabled:false,
+          click:async()=>{try{await manager.retrySynchronization()}catch(error){log(error.message)}} },
+        { id:'desktop-login', label:'登录 macOS 时启动', type:'checkbox', enabled:app.isPackaged, checked:app.getLoginItemSettings().openAtLogin,
+          click:async item=>{try{app.setLoginItemSettings({openAtLogin:item.checked});item.checked=app.getLoginItemSettings().openAtLogin}catch(error){item.checked=app.getLoginItemSettings().openAtLogin;await dialog.showMessageBox(window,{type:'error',message:'无法修改登录启动设置',detail:error.message})}} },
+        { id:'desktop-background', label:'登录启动时仅驻留菜单栏', type:'checkbox', checked:preferences.value.backgroundAtLogin,
+          click:async item=>{try{await preferences.setBackgroundAtLogin(item.checked)}catch(error){item.checked=preferences.value.backgroundAtLogin;await dialog.showMessageBox(window,{type:'error',message:error.message})}} },
+        { id:'desktop-update-help', label:'App 更新与回退…', click:()=>dialog.showMessageBox(window,{type:'info',message:`夭夭 App ${packageVersion}`,detail:`App 构建:${String(buildInfo.commit).slice(0,12)}\n当前 Web:${manager.state.version||'尚未就绪'}${manager.state.build?.commit?' · '+manager.state.build.commit.slice(0,12):''}${manager.state.external?'(独立后台服务)':'(开发模式)'}\n\n安装或更新 App 后首次启动,会将较旧的本机 Web 同步到配套构建。已有更新的 Web 会保留;同一个 App 构建不会重复同步。\n\nWeb 可以继续自行升级。独立 Web 服务在退出 App 后继续运行;App 管理的执行节点会停止。\n\n降级通过 Web 的回滚入口单独处理。数据库结构不兼容时,需要独立的数据恢复流程。\n\n数据目录:${home}`,buttons:['知道了','打开数据目录']}).then(result=>{if(result.response===1)shell.showItemInFolder(home)}) },
+        { type: 'separator' },
+        { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' },
+        { type: 'separator' },
+        { id:'desktop-quit',label:'退出夭夭(后台继续运行)',accelerator:'CommandOrControl+Q',click:()=>requestQuit() },
+        { id:'desktop-stop-and-quit',label:'停止后台服务并退出',click:()=>requestQuit(true) }] },
+      {label:'执行节点',submenu:[{id:'runner-status',label:'未配置执行节点',enabled:false},{type:'separator'},
+        {id:'runner-import',label:'导入节点配置…',click:importRunner},
+        {id:'runner-reconnect',label:'重新连接',click:()=>runnerAction(()=>runnerManager.start())},
+        {id:'runner-forget',label:'断开并忘记配置',click:()=>runnerAction(()=>runnerManager.forget())}]},
+      { role: 'editMenu', label: '编辑' }, { role: 'viewMenu', label: '显示' }, { role: 'windowMenu', label: '窗口' },
+    ]))
+    const icon = nativeImage.createFromPath(join(desktopRoot, 'icon.png')).resize({ width: 20, height: 20 })
+    icon.setTemplateImage(true)
+    tray = new Tray(icon)
+    tray.setToolTip('夭夭')
+    tray.setContextMenu(Menu.buildFromTemplate([{ label: '打开夭夭', click: show }, { label: '查看日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' }, { label: '退出夭夭(后台继续运行)', click: () => requestQuit() }, { label: '停止后台服务并退出', click: () => requestQuit(true) }]))
+    tray.on('click', show)
+    createWindow()
+    powerMonitor.on('resume', () => { if(!closing&&!quitting)void manager.check() })
+    timer = setInterval(() => { void manager.check() }, 10_000); timer.unref()
+    await manager.start().catch(error => log(error.message))
+    if(!closing)await runnerManager.start().catch(error=>{runnerManager.publish(error.message)})
+  }).catch(error => { log(error.stack || error.message); quitting = true; app.quit() })
+}

+ 12 - 0
desktop/preferences.mjs

@@ -0,0 +1,12 @@
+import {readFile,writeFile,rename,mkdir,rm} from 'node:fs/promises'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+export class DesktopPreferences {
+  constructor(home){this.home=home;this.path=join(home,'desktop-preferences.json');this.value={backgroundAtLogin:false}}
+  async load(){try{const value=JSON.parse(await readFile(this.path,'utf8'));this.value={backgroundAtLogin:value.backgroundAtLogin===true}}catch(error){if(error.code!=='ENOENT')throw new Error('无法读取桌面偏好设置')}return this.value}
+  async setBackgroundAtLogin(enabled){
+    const value={backgroundAtLogin:enabled===true},temporary=this.path+'.'+randomUUID()
+    await mkdir(this.home,{recursive:true,mode:0o700})
+    try{await writeFile(temporary,JSON.stringify(value),{flag:'wx',mode:0o600});await rename(temporary,this.path);this.value=value}finally{await rm(temporary,{force:true})}
+  }
+}

+ 18 - 0
desktop/preferences.test.mjs

@@ -0,0 +1,18 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {mkdtemp,rm,stat} from 'node:fs/promises'
+import {join} from 'node:path'
+import {tmpdir} from 'node:os'
+import {DesktopPreferences} from './preferences.mjs'
+test('background login remains opt-in and persists without changing system login permission',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-desktop-preferences-'))
+  try{
+    const preferences=new DesktopPreferences(home)
+    assert.deepEqual(await preferences.load(),{backgroundAtLogin:false})
+    await preferences.setBackgroundAtLogin(true)
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:true})
+    assert.equal((await stat(preferences.path)).mode&0o777,0o600)
+    await preferences.setBackgroundAtLogin(false)
+    assert.deepEqual(await new DesktopPreferences(home).load(),{backgroundAtLogin:false})
+  }finally{await rm(home,{recursive:true,force:true})}
+})

+ 6 - 0
desktop/preload.cjs

@@ -0,0 +1,6 @@
+const { contextBridge, ipcRenderer } = require('electron')
+contextBridge.exposeInMainWorld('yaoyaoDesktop', Object.freeze({
+  status: () => ipcRenderer.invoke('desktop:status'),
+  retry: () => ipcRenderer.invoke('desktop:retry'),
+  logs: () => ipcRenderer.invoke('desktop:logs'),
+}))

+ 98 - 0
desktop/runner-manager.mjs

@@ -0,0 +1,98 @@
+import { readFile, writeFile, rename, mkdir, rm } from 'node:fs/promises'
+import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+
+/** Native-only credentials and child ownership. No browser IPC exposes this class. */
+export class DesktopRunnerManager {
+  constructor(options) {
+    this.options = options
+    this.path = join(options.home, 'desktop-runner.enc')
+    this.state = '未配置执行节点'
+    this.child = null
+    this.chain = Promise.resolve()
+    this.stopping = false
+    this.generation = 0
+    this.restarts = []
+  }
+  publish(message) { this.state = message; this.options.onState?.(message) }
+  serial(work) { const result = this.chain.catch(() => {}).then(work); this.chain = result; return result }
+  async read() {
+    let encrypted
+    try { encrypted = await readFile(this.path) } catch(error) { if(error.code === 'ENOENT') return; throw error }
+    try { return this.options.validate(JSON.parse(await this.options.decrypt(encrypted))) }
+    catch { throw new Error('无法解锁执行节点配置,请检查钥匙串或重新导入') }
+  }
+  importFile(path) {
+    const generation=++this.generation
+    return this.serial(async () => {
+      let config
+      try { config = this.options.validate(JSON.parse(await readFile(path, 'utf8'))) }
+      catch { throw new Error('执行节点配置无效,请从夭夭重新下载配置') }
+      // Encrypt before stopping a working node: failed Keychain access preserves it.
+      if(generation!==this.generation)return
+      const bytes = await this.options.encrypt(JSON.stringify(config))
+      if(generation!==this.generation)return
+      await this.stopChild()
+      if(generation!==this.generation)return
+      await mkdir(this.options.home, { recursive: true, mode: 0o700 })
+      const temporary = `${this.path}.${randomUUID()}`
+      try { await writeFile(temporary, bytes, { flag: 'wx', mode: 0o600 }); await rename(temporary, this.path) }
+      finally { await rm(temporary, { force: true }) }
+      if(generation!==this.generation)return
+      this.stopping = false; this.restarts = []
+      await this.startChild(config)
+    })
+  }
+  start() { const generation=++this.generation; return this.serial(async () => { const config=await this.read();if(generation!==this.generation)return;this.stopping=false;if(config)await this.startChild(config) }) }
+  async startChild(config) {
+    if(this.child || this.stopping) return
+    const child = this.options.fork()
+    this.child = child
+    this.stopFailure = undefined
+    // Drain diagnostics without persisting possible upstream or parser secrets.
+    child.stdout?.resume();child.stderr?.resume()
+    this.publish('执行节点正在连接')
+    child.once('spawn', () => {
+      if(this.child !== child || this.stopping) { child.postMessage({type:'shutdown'}); return }
+      child.postMessage({ type: 'configure', config, home: join(this.options.home, 'runner-state') })
+    })
+    child.on('message', message => {
+      if(this.child !== child) return
+      if(['status','error'].includes(message?.type) && typeof message.message === 'string') this.publish(message.message.slice(0,200))
+    })
+    child.exited = new Promise(resolve => child.once('exit', code => {
+      child.finished = true;child.exitCode=code
+      if(this.stopping&&code!==0)this.stopFailure=new Error('执行节点异常退出,电脑停止状态待确认')
+      resolve()
+      if(this.child !== child) return
+      this.child = null
+      if(this.stopping) return
+      if(code === 0) { this.publish('执行节点已停止'); return }
+      this.restarts = this.restarts.filter(time => Date.now()-time < 60000)
+      if(this.restarts.length >= 3) { this.publish('执行节点反复退出,请检查配置后重连'); return }
+      this.restarts.push(Date.now())
+      this.publish('执行节点意外退出,正在重连')
+      this.restartTimer = setTimeout(() => { void this.start().catch(() => this.publish('无法读取执行节点配置,请重新导入')) },1000)
+      this.restartTimer.unref()
+    }))
+  }
+  async stopChild() {
+    this.stopping = true; clearTimeout(this.restartTimer)
+    const child = this.child
+    if(!child){if(this.stopFailure)throw this.stopFailure;return}
+    child.postMessage({type:'shutdown'})
+    let timer
+    try { await Promise.race([child.exited,new Promise(resolve => { timer = setTimeout(resolve,30000) })]) }
+    finally { clearTimeout(timer) }
+    if(!child.finished) {
+      child.kill()
+      try { await Promise.race([child.exited,new Promise(resolve => { timer = setTimeout(resolve,5000) })]) }
+      finally { clearTimeout(timer) }
+    }
+    if(!child.finished) throw new Error('执行节点尚未确认退出,暂不启动替代进程')
+    if(this.child === child) this.child = null
+    if(this.stopFailure)throw this.stopFailure
+  }
+  stop() { this.generation++;this.stopping = true; clearTimeout(this.restartTimer); return this.serial(() => this.stopChild()) }
+  forget() { this.generation++;this.stopping = true; return this.serial(async () => { await this.stopChild(); await rm(this.path,{force:true}); this.publish('未配置执行节点') }) }
+}

+ 48 - 0
desktop/runner-manager.test.mjs

@@ -0,0 +1,48 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {EventEmitter} from 'node:events'
+import {mkdtemp,readFile,writeFile,stat,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID,randomBytes,createCipheriv,createDecipheriv} from 'node:crypto'
+import {DesktopRunnerManager} from './runner-manager.mjs'
+
+async function fixture(run){
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-native-runner-')),key=randomBytes(32),children=[]
+  const encrypt=value=>{const iv=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,iv);const bytes=Buffer.concat([cipher.update(value),cipher.final()]);return Buffer.concat([iv,cipher.getAuthTag(),bytes])}
+  const decrypt=bytes=>{const cipher=createDecipheriv('aes-256-gcm',key,bytes.subarray(0,12));cipher.setAuthTag(bytes.subarray(12,28));return Buffer.concat([cipher.update(bytes.subarray(28)),cipher.final()]).toString()}
+  const manager=new DesktopRunnerManager({home,encrypt,decrypt,validate:value=>{if(value.protocol!==1)throw new Error('invalid');return value},fork:()=>{
+    const child=new EventEmitter();child.messages=[]
+    child.postMessage=value=>{child.messages.push(value);if(value.type==='shutdown')queueMicrotask(()=>child.emit('exit',0))}
+    child.kill=()=>{queueMicrotask(()=>child.emit('exit',0));return true}
+    children.push(child);queueMicrotask(()=>child.emit('spawn'));return child
+  }})
+  const config={protocol:1,runnerId:randomUUID(),token:randomBytes(32).toString('base64url'),serverURL:'https://fixture.invalid',hermesURL:'http://127.0.0.1:9119',allowedProfiles:['default'],artifactRoots:[]}
+  const file=join(home,'download.json');await writeFile(file,JSON.stringify(config))
+  try{await run({home,manager,children,config,file})}finally{await manager.stop();await rm(home,{recursive:true,force:true})}
+}
+test('stores only encrypted credentials and restarts from private IPC configuration',()=>fixture(async({manager,children,config,file})=>{
+  await manager.importFile(file)
+  const encrypted=await readFile(manager.path)
+  assert.equal(encrypted.includes(config.token),false)
+  assert.equal((await stat(manager.path)).mode&0o077,0)
+  assert.deepEqual(children[0].messages.find(m=>m.type==='configure').config,config)
+  await manager.stop();await manager.start()
+  assert.equal(children.length,2)
+  assert.deepEqual(children[1].messages.find(m=>m.type==='configure').config,config)
+  await manager.forget()
+  await assert.rejects(readFile(manager.path),{code:'ENOENT'})
+  await manager.start();assert.equal(children.length,2)
+}))
+test('a Keychain failure preserves a running node and its previous encrypted configuration',()=>fixture(async({manager,children,file})=>{
+  await manager.importFile(file);const encrypted=await readFile(manager.path)
+  manager.options.encrypt=()=>{throw new Error('keychain unavailable')}
+  await assert.rejects(manager.importFile(file),/keychain/)
+  assert.equal(children[0].finished,undefined)
+  assert.deepEqual(await readFile(manager.path),encrypted)
+}))
+test('a queued import cannot start a node after stop was requested',()=>fixture(async({manager,children,file})=>{
+  const imported=manager.importFile(file),stopped=manager.stop()
+  await Promise.all([imported,stopped])
+  assert.equal(children.length,0)
+}))

+ 259 - 0
desktop/service-manager.mjs

@@ -0,0 +1,259 @@
+import { readFile, realpath, mkdir } from 'node:fs/promises'
+import { join } from 'node:path'
+import { createHash } from 'node:crypto'
+import { request as httpRequest } from 'node:http'
+import { request as httpsRequest } from 'node:https'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+
+const exec = promisify(execFile)
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
+export const dataKey = root => createHash('sha256').update(root).digest('hex')
+
+export function localJSON(url, headers = {}, method = 'GET', timeout = 1500) {
+  const target = new URL(url)
+  if (!['http:', 'https:'].includes(target.protocol) || !['127.0.0.1', '[::1]'].includes(target.hostname) || target.username || target.password)
+    return Promise.reject(new Error('桌面服务必须使用本机回环地址。'))
+  return new Promise((resolve, reject) => {
+    const req = (target.protocol === 'https:' ? httpsRequest : httpRequest)(target, { method, headers, agent: false }, res => {
+      let body = '', bytes = 0
+      res.on('data', chunk => {
+        bytes += chunk.length
+        if (bytes > 64 * 1024) req.destroy(new Error('服务身份响应过大'))
+        else body += chunk
+      })
+      res.on('end', () => {
+        if (res.statusCode !== 200) { reject(new Error(`服务返回 HTTP ${res.statusCode}`)); return }
+        try { resolve(JSON.parse(body)) } catch { reject(new Error('服务返回了无效响应')) }
+      })
+    })
+    req.setTimeout(timeout, () => req.destroy(new Error('服务连接超时')))
+    req.on('error', reject); req.end()
+  })
+}
+
+function isAlive(pid) {
+  if (!Number.isSafeInteger(pid) || pid < 1) return false
+  try { process.kill(pid, 0); return true } catch (e) { return e.code !== 'ESRCH' }
+}
+
+/** One owner for a packaged service child. Existing services are connected to,
+ * never stopped. All callbacks are fenced to their exact child generation. */
+export class DesktopServiceManager {
+  constructor(options) {
+    this.options = options
+    this.state = { phase: 'stopped', message: '本地服务尚未启动' }
+    this.child = null
+    this.generation = 0
+    this.stopping = false
+    this.restarts = []
+  }
+  publish(value) {
+    if (value.phase === 'ready' && this.updateNotice) value = { ...value, updateNotice: this.updateNotice, message: `${value.message},Web 同步待完成` }
+    this.state = value; this.options.onState?.(value)
+  }
+  async retrySynchronization() {
+    if (this.starting) return this.starting
+    this.state = { phase: 'stopped', message: '正在准备同步 Web…' }
+    return this.start()
+  }
+  async reconnect() {
+    const generation=this.generation
+    if (this.state.phase === 'ready' && !this.stopping) {
+      await this.check()
+      if(this.stopping||generation!==this.generation)return this.state
+    }
+    if (this.state.phase === 'ready' && !this.stopping) return this.state
+    clearTimeout(this.restartTimer)
+    return this.start()
+  }
+  async readRecord() {
+    try {
+      const r = JSON.parse(await readFile(join(this.root, 'service-instance.json'), 'utf8'))
+      return r.protocol === 1 && r.dataKey === dataKey(this.root) && isAlive(r.pid) ? r : null
+    } catch { return null }
+  }
+  async verify(record) {
+    if (!record.url) throw new Error('服务尚未监听')
+    const url = new URL(record.url)
+    if (url.hostname === '0.0.0.0') url.hostname = '127.0.0.1'
+    const actual = await localJSON(new URL('/desktop/service', url), { 'x-yaoyao-desktop-token': record.token })
+    if (actual.protocol !== 1 || actual.instanceId !== record.instanceId || actual.pid !== record.pid || actual.dataKey !== dataKey(this.root) || actual.version !== record.version || typeof actual.version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(actual.version))
+      throw new Error('服务身份与本地数据不匹配')
+    if (this.child?.pid === actual.pid && actual.version !== this.options.version) throw new Error('App 内置服务版本不匹配,请重新安装完整 App')
+    return { ...actual, url: url.origin }
+  }
+  async legacyService() {
+    // Old CLI/LaunchAgent builds lack the new descriptor. Prove the listener
+    // actually holds this directory's SQLite file before reusing its own UI.
+    if (process.platform !== 'darwin') return null
+    const pids = async args => {
+      try { return new Set((await exec('/usr/sbin/lsof', args, { timeout: 3000 })).stdout.trim().split(/\s+/).filter(Boolean)) }
+      catch { return new Set() }
+    }
+    const [holders, listeners] = await Promise.all([
+      pids(['-t', join(this.root, 'workspace.sqlite3')]),
+      pids(['-nP', `-iTCP:${this.options.port}`, '-sTCP:LISTEN', '-t']),
+    ])
+    if (!holders.size) return null
+    if (holders.size !== 1 || !listeners.has([...holders][0])) throw new Error('数据目录正在被另一个服务使用,请先确认已有服务。')
+    const url = `http://127.0.0.1:${this.options.port}`
+    const status = await localJSON(new URL('/api/status', url))
+    if (status.server_kind !== 'yaoyao-web') throw new Error('已有端口不是夭夭服务。')
+    return { url, pid: Number([...holders][0]), external: true, legacy: true, version: '已有服务' }
+  }
+  async start() {
+    if (this.starting) return this.starting
+    if (this.state.phase === 'ready' && !this.stopping) return this.state
+    this.stopping = false
+    this.starting = this.boot().finally(() => { this.starting = null })
+    return this.starting
+  }
+  async boot() {
+    const generation = ++this.generation
+    this.publish({ phase: 'starting', message: '正在启动本地服务…' })
+    try {
+      await mkdir(this.options.home, { recursive: true, mode: 0o700 })
+      this.root = await realpath(this.options.home)
+      this.updateNotice = undefined
+      if (this.options.synchronize) {
+        try { await this.options.synchronize(message => this.publish({ phase: 'starting', message })) }
+        catch (error) {
+          // Busy is reported before stopping or switching. Other failures still
+          // fail closed; the existing service must pass the usual identity checks.
+          if (error.code !== 'service_busy') throw error
+          this.updateNotice = error.message
+        }
+        if (generation !== this.generation || this.stopping) return
+      }
+      let record = await this.readRecord()
+      if (generation !== this.generation || this.stopping) return
+      if (record?.url) {
+        const service = await this.verify(record)
+        if (generation !== this.generation || this.stopping) return
+        this.publish({ phase: 'ready', message: `已连接已有服务(${service.version})`, ...service, external: !this.child })
+        return this.state
+      }
+      const legacy = !record ? await this.legacyService() : null
+      if (generation !== this.generation || this.stopping) return
+      if (legacy) { this.publish({ phase: 'ready', message: '已连接已有后台服务', ...legacy }); return this.state }
+      if (!record) {
+        if (this.options.synchronize) throw new Error('独立后台服务尚未就绪,请稍后重试')
+        const child = this.options.fork({ home: this.root, port: this.options.port })
+        child.desktopExited = false
+        this.child = child
+        child.stdout?.on('data', b => this.options.log?.(String(b)))
+        child.stderr?.on('data', b => this.options.log?.(String(b)))
+        child.once('exit', code => {
+          child.desktopExited = true
+          if (this.child !== child) return
+          this.child = null
+          if (this.stopping || this.state.phase === 'starting') return
+          this.options.log?.(`服务进程退出:${code}`)
+          this.scheduleRestart(this.generation)
+        })
+      }
+      const deadline = Date.now() + (this.options.timeoutMs ?? 30_000)
+      let lastError
+      while (Date.now() < deadline && !this.stopping && generation === this.generation) {
+        record = await this.readRecord()
+        if (record?.url) {
+          try {
+            const service = await this.verify(record)
+            if (this.child && service.pid !== this.child.pid) throw new Error('服务启动期间归属发生变化')
+            if (generation !== this.generation || this.stopping) return
+            this.publish({ phase: 'ready', message: '本地服务已就绪', ...service, external: !this.child })
+            return this.state
+          } catch (e) { lastError = e }
+        }
+        if (!this.child && !record) throw new Error('服务进程未能启动,请查看日志后重试。')
+        await sleep(100)
+      }
+      if (!this.stopping) throw new Error(lastError?.message || '服务启动超时,请查看日志后重试。')
+    } catch (error) {
+      if (generation !== this.generation || this.stopping) return
+      await this.stopChild()
+      this.publish({ phase: 'error', message: error.message })
+      throw error
+    }
+  }
+  scheduleRestart(generation) {
+    const now = Date.now()
+    this.restarts = this.restarts.filter(time => now - time < 5 * 60_000)
+    if (this.restarts.length >= (this.options.synchronize ? 12 : 3)) { this.publish({ phase: 'error', message: '服务多次断开,请查看日志后重试。' }); return }
+    this.restarts.push(now)
+    this.publish({ phase: 'restarting', message: '服务已断开,正在恢复…' })
+    this.restartTimer = setTimeout(() => {
+      if (this.stopping || generation !== this.generation) return
+      void this.start().catch(() => { if (this.options.synchronize && !this.stopping) this.scheduleRestart(this.generation) })
+    }, this.options.synchronize ? 5000 : 500 * this.restarts.length)
+  }
+  async check() {
+    if (this.state.phase !== 'ready') return
+    const generation = this.generation
+    try {
+      if (this.state.legacy) {
+        if (!isAlive(this.state.pid)) throw new Error('已有服务已停止')
+        await localJSON(new URL('/api/status', this.state.url))
+      } else {
+        const record = await this.readRecord()
+        if (!record) throw new Error('服务已停止')
+        const service = await this.verify(record)
+        if (service.pid !== this.state.pid || service.version !== this.state.version || service.build?.commit !== this.state.build?.commit)
+          this.publish({ phase: 'ready', message: `已连接后台服务(${service.version})`, ...service, external: !this.child })
+      }
+    } catch {
+      if (this.stopping || generation !== this.generation) return
+      if (this.state.external) this.scheduleRestart(generation)
+      else this.publish({ phase: 'error', message: '本地服务连接已断开,请重试。' })
+    }
+  }
+  async stopChild() {
+    const child = this.child
+    if (!child || child.desktopExited) return
+    const exited = new Promise(resolve => child.once('exit', resolve))
+    const record = await this.readRecord()
+    if (record?.pid === child.pid && record.desktopOwned) {
+      try { await localJSON(new URL('/desktop/service', record.url), { 'x-yaoyao-desktop-token': record.token }, 'DELETE') }
+      catch { /* Owned process handle remains authoritative for cleanup. */ }
+    } else child.kill()
+    let timer
+    await Promise.race([exited, new Promise(resolve => { timer = setTimeout(() => { child.kill(); resolve() }, 5000) })])
+    clearTimeout(timer)
+    if (!child.desktopExited) {
+      await Promise.race([exited, sleep(2000)])
+      if (!child.desktopExited && child.pid) {
+        try { process.kill(child.pid, 'SIGKILL') } catch (error) { if (error.code !== 'ESRCH') throw error }
+        await Promise.race([exited, sleep(2000)])
+      }
+      if (!child.desktopExited) throw new Error('旧服务进程尚未确认退出,暂不启动替代进程。')
+    }
+    if (this.child === child) this.child = null
+  }
+  async stop() {
+    const owned = Boolean(this.child)
+    this.stopping = true
+    clearTimeout(this.restartTimer)
+    ++this.generation
+    await this.starting?.catch(() => {})
+    await this.stopChild()
+    this.publish(owned ? { phase: 'stopped', message: '后台服务已停止' }
+      : { phase: 'disconnected', message: 'App 已断开后台连接' })
+  }
+  async stopBackground() {
+    this.stopping = true
+    clearTimeout(this.restartTimer)
+    ++this.generation
+    await this.starting?.catch(() => {})
+    this.publish({phase:'stopping',message:'正在停止后台服务…'})
+    try {
+      if(this.options.stopBackground)await this.options.stopBackground(message=>this.publish({phase:'stopping',message}))
+      else if(this.child)await this.stopChild()
+      else throw new Error('此后台服务不由本 App 管理,请在它的启动位置停止')
+      this.publish({phase:'stopped',message:'后台服务已停止'})
+    } catch(error) {
+      this.publish({phase:'error',message:'后台服务停止未完成,请重新连接检查'})
+      throw error
+    }
+  }
+}

+ 71 - 0
desktop/service-manager.test.mjs

@@ -0,0 +1,71 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {createServer} from 'node:http'
+import {dataKey,DesktopServiceManager} from './service-manager.mjs'
+import {mkdtemp,realpath,writeFile,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+test('checks recorded and bundled versions while permitting a separately owned older service',async()=>{
+  const root='/fixture/desktop',record={protocol:1,instanceId:'fixture',pid:1234,dataKey:dataKey(root),version:'0.3.32',token:'fixture'}
+  let actual={...record};delete actual.token
+  const server=createServer((request,response)=>{response.setHeader('Content-Type','application/json');response.end(JSON.stringify(actual))})
+  await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
+  record.url=`http://127.0.0.1:${server.address().port}`
+  const manager=new DesktopServiceManager({version:'0.3.33'});manager.root=root
+  try{
+    assert.equal((await manager.verify(record)).version,'0.3.32')
+    actual={...actual,version:'0.3.31'}
+    await assert.rejects(manager.verify(record),/身份/)
+    actual={...actual,version:'0.3.32'};manager.child={pid:record.pid}
+    await assert.rejects(manager.verify(record),/版本不匹配/)
+  }finally{await new Promise(resolve=>server.close(resolve))}
+})
+test('busy synchronization connects the verified existing Web and remains retryable without accepting other errors',async()=>{
+  const home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-startup-busy-')))
+  const record={protocol:1,instanceId:'busy-fixture',pid:process.pid,dataKey:dataKey(home),version:'0.3.31',token:'fixture'}
+  const server=createServer((request,response)=>{const {token,...identity}=record;response.setHeader('Content-Type','application/json');response.end(JSON.stringify(identity))})
+  await new Promise(done=>server.listen(0,'127.0.0.1',done))
+  record.url=`http://127.0.0.1:${server.address().port}`
+  await writeFile(join(home,'service-instance.json'),JSON.stringify(record))
+  let attempts=0
+  const options={home,version:'0.3.32',fork:()=>{throw new Error('must not fork')},synchronize:async()=>{if(++attempts===1)throw Object.assign(new Error('已有任务,同步待完成'),{code:'service_busy'})}}
+  const manager=new DesktopServiceManager(options)
+  try{
+    const state=await manager.start()
+    assert.equal(state.phase,'ready');assert.equal(state.external,true);assert.match(state.updateNotice,/同步待完成/)
+    assert.equal((await manager.retrySynchronization()).updateNotice,undefined);assert.equal(attempts,2)
+    await manager.stop()
+    assert.equal(manager.state.phase,'disconnected')
+    assert.doesNotMatch(manager.state.message,/服务已停止/)
+    assert.equal((await manager.reconnect()).phase,'ready')
+    assert.equal(manager.state.pid,process.pid)
+    await assert.rejects(manager.stopBackground(),/不由本 App 管理/)
+    assert.equal(manager.state.phase,'error')
+    assert.equal((await manager.reconnect()).phase,'ready')
+    const invalid=new DesktopServiceManager({...options,synchronize:async()=>{throw new Error('invalid package')}})
+    await assert.rejects(invalid.start(),/invalid package/)
+    assert.equal(invalid.state.phase,'error')
+  }finally{await new Promise(done=>server.close(done));await rm(home,{recursive:true,force:true})}
+})
+test('publishes stopped only after the native service manager confirms shutdown',async()=>{
+ let finish,fail=false
+ const manager=new DesktopServiceManager({stopBackground:()=>fail?Promise.reject(new Error('still alive')):new Promise(resolve=>{finish=resolve})})
+ const stopping=manager.stopBackground()
+ await new Promise(resolve=>setImmediate(resolve))
+ assert.equal(manager.state.phase,'stopping')
+ finish();await stopping
+ assert.equal(manager.state.phase,'stopped')
+ fail=true;await assert.rejects(manager.stopBackground(),/still alive/)
+ assert.equal(manager.state.phase,'error')
+ assert.doesNotMatch(manager.state.message,/服务已停止/)
+})
+test('a late activate health check cannot reconnect after quit has begun',async()=>{
+ const manager=new DesktopServiceManager({})
+ manager.state={phase:'ready'}
+ let finish,starts=0
+ manager.check=()=>new Promise(resolve=>{finish=resolve})
+ manager.start=async()=>{starts++}
+ const reconnect=manager.reconnect()
+ await manager.stop();finish();await reconnect
+ assert.equal(starts,0);assert.equal(manager.state.phase,'disconnected')
+})

+ 31 - 0
desktop/service-sync.mjs

@@ -0,0 +1,31 @@
+import { spawn } from 'node:child_process'
+import { join } from 'node:path'
+
+/** A native helper owns the update until completion, even if the window closes.
+ * The renderer never receives its configuration or a process-control bridge. */
+export const synchronizeLocalService = options => serviceCommand({...options,action:'sync'})
+export const stopLocalService = options => serviceCommand({...options,action:'stop'})
+function serviceCommand({ home, port, root, fixture, environment, onProgress = () => {}, action }) {
+  return new Promise((done, reject) => {
+    const runtimeRoot = join(root, 'web-service')
+    const helper = spawn(join(runtimeRoot, 'node'), [join(runtimeRoot, 'bin', 'desktop-service-sync.mjs')], {
+      cwd: runtimeRoot, detached: true, stdio: ['pipe', 'pipe', 'ignore'],
+      env: { HOME: process.env.HOME, PATH: process.env.PATH || '/usr/bin:/bin:/usr/sbin:/sbin', NODE_USE_ENV_PROXY: '0' },
+    })
+    let buffer = '', result, failure, failureCode
+    helper.stdout.on('data', bytes => {
+      buffer += bytes
+      if (buffer.length > 256 * 1024) { failure = '同步进程输出异常'; return }
+      for (;;) {
+        const index = buffer.indexOf('\n'); if (index < 0) break
+        const line = buffer.slice(0, index); buffer = buffer.slice(index + 1)
+        try { const value = JSON.parse(line); if (value.message) onProgress(value.message); if (value.result) result = value.result; if (value.error) { failure = value.error; failureCode = value.code } }
+        catch { failure = '同步进程响应无效' }
+      }
+    })
+    helper.once('error', reject)
+    helper.once('exit', code => code === 0 && result ? done(result) : reject(Object.assign(new Error(failure || (action==='stop'?'后台服务停止未完成':'本机 Web 同步未完成,请重试')), { code: failureCode })))
+    helper.stdin.on('error', () => {})
+    helper.stdin.end(JSON.stringify({ home, port, runtimeRoot, fixture, environment, action }))
+  })
+}

+ 141 - 0
desktop/service-sync.test.mjs

@@ -0,0 +1,141 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, readFile, writeFile, rm, cp, realpath } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+import { createServer } from 'node:net'
+import { spawn } from 'node:child_process'
+import { createHash } from 'node:crypto'
+import { _electron as electron } from '@playwright/test'
+import { LaunchAgentService, localRequest, synchronizeDesktop } from '../bin/lib/service-update.mjs'
+import { sealRuntime, verifyRuntimePackage } from '../bin/lib/runtime-release.mjs'
+
+const root = resolve(import.meta.dirname, '..')
+const payloadRoot = process.env.DESKTOP_TEST_EXECUTABLE
+  ? resolve(dirname(process.env.DESKTOP_TEST_EXECUTABLE), '../Resources/runtime/web-service')
+  : join(root, '.desktop-build', 'web-service')
+async function freePort() {
+  const server = createServer(); await new Promise(done => server.listen(0, '127.0.0.1', done))
+  const port = server.address().port; await new Promise(done => server.close(done)); return port
+}
+async function until(read, accept, timeout = 60000) {
+  const deadline = Date.now() + timeout
+  while (Date.now() < deadline) {
+    try { const value = await read(); if (accept(value)) return value } catch {}
+    await new Promise(done => setTimeout(done, 200))
+  }
+  throw new Error('等待同步服务状态超时')
+}
+async function nativeQuit(app, menu = false) {
+  let exited=false;app.process().once('exit',()=>{exited=true})
+  let output='';app.process().stdout.on('data',bytes=>{output+=bytes})
+  await app.evaluate(({app,BrowserWindow})=>{
+    const navigations=[]
+    BrowserWindow.getAllWindows()[0].webContents.on('did-start-navigation',(_event,url)=>navigations.push(url))
+    app.once('will-quit',()=>console.log('QUIT_TEST_NAVIGATIONS='+JSON.stringify(navigations)))
+  })
+  await app.evaluate(({app,Menu},menu)=>{if(menu)Menu.getApplicationMenu().getMenuItemById('desktop-quit').click();else app.quit()},menu)
+  await until(()=>exited,Boolean,10000)
+  const proof=output.match(/QUIT_TEST_NAVIGATIONS=(\[[^\n]*\])/)
+  assert.ok(proof,'native will-quit must complete')
+  assert.equal(JSON.parse(proof[1]).some(url=>url.includes('boot.html')),false,'native quit must not navigate to a stopped boot page')
+  await app.close().catch(()=>{})
+}
+
+test('App first launch installs an independent Web; Web remains up after quit and a newer Web is retained', { timeout: 150000 }, async () => {
+  const home = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-sync-native-')))
+  const port = await freePort(), upstreamPort = await freePort()
+  const releaseRoot = join(home, 'updates', 'fixture-releases')
+  const label = `cn.samien.yaoyao.sync-test.${createHash('sha256').update(home).digest('hex').slice(0, 16)}`
+  const driver = new LaunchAgentService({ home, port, releaseRoot, label, plistPath: join(home, 'updates', 'fixture-service.plist') })
+  const upstream = spawn(process.execPath, [join(root, 'tests/fixtures/fake-hermes.mjs')], {
+    cwd: root, env: { ...process.env, FAKE_HERMES_PORT: String(upstreamPort), FAKE_HERMES_LOCAL_AUTH: '1' }, stdio: 'ignore',
+  })
+  let app
+  const launch = () => electron.launch({
+    ...(process.env.DESKTOP_TEST_EXECUTABLE ? { executablePath: process.env.DESKTOP_TEST_EXECUTABLE } : {}),
+    args: process.env.DESKTOP_TEST_EXECUTABLE ? [] : [root], cwd: root,
+    env: { ...process.env, HERMES_YAOYAO_DESKTOP_TEST_HOME: home, HERMES_YAOYAO_DESKTOP_PORT: String(port),
+      HERMES_YAOYAO_DESKTOP_TEST_SYNC: '1', HERMES_YAOYAO_UPSTREAM: `http://127.0.0.1:${upstreamPort}` }, timeout: 30000,
+  })
+  try {
+    await until(() => localRequest(`http://127.0.0.1:${upstreamPort}/api/status`), value => value.status === 200)
+    app = await launch()
+    const page = await app.firstWindow()
+    await page.waitForURL(`http://127.0.0.1:${port}/**`, { timeout: 90000 })
+    const record = JSON.parse(await readFile(join(home, 'service-instance.json'), 'utf8'))
+    assert.equal(record.desktopOwned, false)
+    const identity = await localRequest(`http://127.0.0.1:${port}/desktop/service`, record.token)
+    const original = verifyRuntimePackage(payloadRoot)
+    assert.equal(identity.body.build.artifactDigest, original.artifactDigest)
+    assert.equal(identity.body.quiesced, false)
+    const support = await page.evaluate(async () => {
+      const bootstrap = await (await fetch('/api/app/bootstrap')).json()
+      const setup = await fetch('/api/app/setup', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': bootstrap.csrfToken },
+        body: JSON.stringify({ username: 'sync-fixture', password: 'sync-fixture-password' }) })
+      if (!setup.ok) throw new Error('验收账号创建失败')
+      return (await fetch('/api/app/system/update/status')).json()
+    })
+    assert.equal(support.installationMode, 'release'); assert.equal(support.supported, true)
+    await nativeQuit(app); app = undefined
+    assert.equal((await localRequest(`http://127.0.0.1:${port}/healthz`)).body.ok, true)
+    assert.equal(driver.pid(), record.pid, 'quitting the App must leave the independent Web running')
+    const saved = driver.snapshot().plist
+    saved.EnvironmentVariables.YAOYAO_TEST_PRESERVE = 'keep-existing-settings'
+    driver.writePlist(saved)
+
+    // Install a later release through the same transaction engine used by Web's updater.
+    const newer = join(home, 'updates', 'newer-package')
+    await cp(payloadRoot, newer, { recursive: true })
+    await writeFile(join(newer, 'release.json'), JSON.stringify({ schemaVersion: 1, releaseVersion: '0.3.33', webVersion: '0.3.33', gitTag: 'v0.3.33' }))
+    const build = JSON.parse(await readFile(join(newer, 'build-info.json'), 'utf8'))
+    await writeFile(join(newer, 'build-info.json'), JSON.stringify({ ...build, commit: 'c'.repeat(40), ancestors: [build.commit, ...build.ancestors], dirty: false }))
+    sealRuntime(newer)
+    await synchronizeDesktop({ home, runtimeRoot: newer, releaseRoot, driver })
+    const updated = JSON.parse(await readFile(join(home, 'service-instance.json'), 'utf8'))
+    assert.equal(updated.version, '0.3.33'); assert.notEqual(updated.pid, record.pid)
+    assert.equal(driver.snapshot().plist.EnvironmentVariables.YAOYAO_TEST_PRESERVE, 'keep-existing-settings')
+    app = await launch()
+    const reopened = await app.firstWindow(); await reopened.waitForURL(`http://127.0.0.1:${port}/**`, { timeout: 90000 })
+    assert.equal(driver.pid(), updated.pid, 'older App must retain the newer Web without restarting it')
+    const marker = JSON.parse(await readFile(join(home, 'updates', 'desktop-sync.json'), 'utf8'))
+    assert.equal(marker.action, 'newer')
+    assert.equal(marker.artifactDigest, original.artifactDigest)
+    // Keep an authenticated event stream open. Stop-and-quit must close it,
+    // unload launchd (not just kill its PID), and exit with one menu action.
+    await reopened.evaluate(async()=>{
+      const capabilities=await (await fetch('/api/realtime/capabilities')).json()
+      const created=await fetch('/api/realtime/channels',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':capabilities.csrfToken},body:JSON.stringify({channel:'chat'})})
+      if(!created.ok)throw new Error('fixture realtime channel unavailable')
+      const channel=await created.json()
+      const response=await fetch(`/api/realtime/channels/${channel.id}/events`)
+      if(!response.ok)throw new Error('fixture event stream unavailable')
+      if(!response.headers.get('content-type')?.includes('text/event-stream'))throw new Error('expected a real SSE stream')
+      window.stopTestStream=response.body.getReader()
+      void window.stopTestStream.read()
+    })
+    let exited=false;app.process().once('exit',()=>{exited=true})
+    await app.evaluate(({Menu})=>Menu.getApplicationMenu().getMenuItemById('desktop-stop-and-quit').click())
+    await until(()=>exited,Boolean,30000);app=undefined
+    assert.equal(driver.pid(),undefined)
+    assert.equal(driver.listeners().size,0)
+    await new Promise(done=>setTimeout(done,1000))
+    assert.equal(driver.pid(),undefined,'launchd must not relaunch a deliberately stopped service')
+    app = await launch()
+    const restarted = await app.firstWindow(); await restarted.waitForURL(`http://127.0.0.1:${port}/**`, { timeout: 90000 })
+    const resumed = JSON.parse(await readFile(join(home, 'service-instance.json'), 'utf8'))
+    assert.equal(resumed.version, '0.3.33'); assert.equal(resumed.desktopOwned, false)
+    assert.notEqual(resumed.pid, updated.pid)
+    await nativeQuit(app,true); app = undefined
+  } catch (error) {
+    // Only this fixture's server log, which contains startup diagnostics.
+    const log = await readFile(join(home, 'service.log'), 'utf8').catch(() => '')
+    if (log) process.stderr.write(log.slice(-3000))
+    throw error
+  } finally {
+    await app?.close().catch(() => {})
+    await driver.stop()
+    upstream.kill('SIGTERM')
+    await rm(home, { recursive: true, force: true })
+  }
+})

+ 44 - 0
desktop/startup-feedback.test.mjs

@@ -0,0 +1,44 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {mkdtemp,realpath,readFile,rm,mkdir} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {createServer} from 'node:http'
+import {createHash} from 'node:crypto'
+import {_electron as electron} from '@playwright/test'
+import {LaunchAgentService,localRequest} from '../bin/lib/service-update.mjs'
+
+const root=resolve(import.meta.dirname,'..')
+test('built startup page shows a real helper error and Retry recovers into the Web',{timeout:90000},async()=>{
+  // The production packaging filter previously omitted this file entirely.
+  assert.ok((await readFile(join(root,'.desktop-build/shell/boot.js'),'utf8')).includes('yaoyaoDesktop.status'))
+  const home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-startup-feedback-')))
+  const blocker=createServer((_request,response)=>{response.setHeader('Content-Type','application/json');response.end('{"ok":true}')})
+  await new Promise(done=>blocker.listen(0,'127.0.0.1',done))
+  const port=blocker.address().port
+  const driver=new LaunchAgentService({home,port,releaseRoot:join(home,'updates','fixture-releases'),
+    label:`cn.samien.yaoyao.sync-test.${createHash('sha256').update(home).digest('hex').slice(0,16)}`,
+    plistPath:join(home,'updates','fixture-service.plist')})
+  let app
+  try{
+    app=await electron.launch({...(process.env.DESKTOP_TEST_EXECUTABLE?{executablePath:process.env.DESKTOP_TEST_EXECUTABLE}:{}),
+      args:process.env.DESKTOP_TEST_EXECUTABLE?[]:[root],cwd:root,
+      env:{...process.env,HERMES_YAOYAO_DESKTOP_TEST_HOME:home,HERMES_YAOYAO_DESKTOP_PORT:String(port),HERMES_YAOYAO_DESKTOP_TEST_SYNC:'1',HERMES_YAOYAO_UPSTREAM:'http://127.0.0.1:1'},timeout:30000})
+    const page=await app.firstWindow()
+    await page.locator('#status[role="alert"]').waitFor({timeout:30000})
+    assert.match(await page.locator('#status').textContent(),/已有服务未注册/)
+    assert.equal(await page.getByRole('button',{name:'重试',exact:true}).isVisible(),true)
+    assert.equal(await page.getByRole('button',{name:'查看日志',exact:true}).isVisible(),true)
+    await mkdir(join(root,'test-results/desktop'),{recursive:true})
+    await page.screenshot({path:join(root,'test-results/desktop/startup-error-retry.png')})
+    await new Promise(done=>blocker.close(done))
+    await page.getByRole('button',{name:'重试',exact:true}).click()
+    await page.waitForURL(`http://127.0.0.1:${port}/**`,{timeout:45000})
+    assert.equal((await localRequest(`http://127.0.0.1:${port}/healthz`)).body.ok,true)
+    await page.screenshot({path:join(root,'test-results/desktop/startup-recovered.png')})
+  }finally{
+    await app?.close().catch(()=>{})
+    if(blocker.listening)await new Promise(done=>blocker.close(done))
+    await driver.stop();await rm(home,{recursive:true,force:true})
+  }
+})

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

@@ -0,0 +1,91 @@
+# Bot 自主组队、电脑环境与 macOS App 完整方案
+
+## 结论与仓库安排
+
+Bot 模式可以让获得授权的管理员 Agent 创建持久成员、建立团队、分派任务,也可以按目标创建临时助手。任务调度、权限和结果由服务端持久保存。macOS App、Web 控制面、Runner 和电脑 Provider 均放在 `hermes-yaoyao`,分支为 `feat/bot-agent-teams`;iOS 和 Android 的协议入口在各自仓库维护。
+
+App 打开时启动或连接本地 Web 服务,按配置启动 Runner,检测并托管缺少的本地 Hermes 服务。关闭窗口保留执行;明确退出仅停止自己的进程。已有外部服务保持其原版本与生命周期,不会被新 App 静默替换。
+
+## 两个参考项目的取舍
+
+| 参考实现 | 吸收的部分 | 本方案的具体选择 |
+|---|---|---|
+| OpenMausBot `server/container-computer.ts` | 不可变镜像、每 Bot 环境、工作目录持久化、驱动健康检查、guest CUA | 采用兼容的 CUA 0.20.0 接口;独立配方随本仓库交付;guest 无外部网卡、不发布 VNC 端口 |
+| OpenMausBot 镜像配方 | 固定基础镜像与驱动/字体 SHA-256、浏览器资料目录 | 派生配方按 Apache-2.0 保留来源与许可证,增加夭夭标识和 Firefox 持久资料 |
+| Rakazo `apps/api/src/team-chat-startup.ts` | 启动缓冲有上限、持久接收/唤醒归属、关闭等待有界 | 控制面保留投递、去重、任务归属、取消状态与恢复代次;结果回到原会话 |
+| Rakazo `packages/core/src/screen-lease.ts` | 递增 fence,旧操作者无法覆盖新操作者 | 电脑租约持久化;人工接管等待在途工具结束,转交同一电脑并递增代次 |
+| Rakazo `0008_team_computers` 数据迁移 | 电脑资源独立于 Bot,区分独立和团队电脑,执行/控制归属独立 | 默认独立电脑;用户明确授权后才能共享;临时助手不能加入共享电脑 |
+| Rakazo 团队桌面测试 | 独立浏览器资料、持久化、旧查看/控制 token 拒绝 | 多个独立电脑可并行;共享电脑输入串行,跨端统一控制凭据与画面版本 |
+
+没有改动两个参考仓库。Rakazo 的同一容器多个桌面并非本实现:这里的多桌面来自不同独立电脑;同一可信共享电脑只有一个桌面。共享成员知道它们会共用浏览器登录资料。
+
+## 系统结构
+
+```mermaid
+flowchart TD
+  Clients[Web / macOS / iOS / Android] --> API[账号 API 与事件同步]
+  Mac[macOS App 生命周期管理] --> API
+  API --> Tasks[持久目标、分派、复核与结果投递]
+  Tasks --> Hub[Runner 网关与授权检查]
+  Hub --> Runner[执行主机上的 Runner]
+  Runner --> Model[Hermes 模型循环]
+  Model --> Bridge[本轮允许的工具桥]
+  Bridge --> Pool[电脑资源池与递增租约]
+  Pool --> Private[独立电脑]
+  Pool --> Shared[用户授权的共享电脑]
+  Private --> Export[校验后的文件产物]
+  Shared --> Export
+  Export --> Tasks
+```
+
+六个对象必须分开:Profile 是模型与能力配置;Agent 是持久身份;团队是成员与角色关系;目标/子任务记录责任和验收;Runner 是执行主机;电脑是可复用资源。聊天会话和窗口都不是任务调度器。
+
+## 自主组队与任务闭环
+
+- 用户对某个 Agent 开启“管理团队”权限。每轮都重新核对,创建的成员默认不继承这个权限。
+- 管理员先创建团队和目标,再提交负责人、依赖、验收条件与输入资料。Agent 可调整计划,但不能把“模型停止输出”当作目标完成。
+- 成员完成后进入复核;管理员接受、返工、等待用户或标记受阻。成功与失败都有结构化结果和产物归属,自动回传原会话。
+- 忙碌时结果排队。投递持久化并去重;重启核对已有执行,不重放结果不确定的命令。
+- 临时助手绑定目标及其当前恢复代次,固定使用隔离电脑。结束、删除或换代后退役,清理固定原 Runner;原会话和已导出产物保留。
+- 归档、停止、撤权后旧任务不能自动复活。用户的新指令才允许重新授权恢复。
+
+当前限制:每队 2–8 人;账号最多 100 个有效 Agent、20 个有效团队;每个目标最多 32 个子任务;临时助手最多 8 个同时有效、32 个累计;每次授权最多 3 次任务尝试、8 次自动唤醒。同一 Agent 串行,全局最多 4 个执行槽。
+
+## 电脑与人工接管
+
+Runner 默认最多同时运行 2 台电脑,可配置 1–8 台;默认最多保存 32 个环境记录。资源在启动前预占,停止无法确认时保持占用。环境使用完整镜像 ID,校验账号、Runner、容器标签、挂载、网络、UID、CPU、内存与进程上限。
+
+模型循环位于 Runner 主机,模型密钥通过私有进程通道传入;终端、文件、浏览器、电脑动作及其子进程在 guest 中执行。关闭原生宿主工具和二次委派出口,能力不足明确报错。Profile 的 cwd 在 guest 中映射为私有工作区的路径,不代表挂载宿主的整个项目目录;资料通过附件、guest 下载和产物导出流转。
+
+默认 guest 没有外部网络。可选公网代理只允许公共地址的 80/443,拒绝内网、宿主、保留地址及混合 DNS 结果。浏览器和命令使用同一受控代理;这不是任意内网网络访问能力。
+
+电脑面板默认只读。人工接管暂停模型,等待已提交工具结束,再转交同一容器;后台浏览器等应用保持运行。输入必须携带本页面凭据、当前代次和近期画面 ID。控制有效期 30 秒,前台续期;断开或撤权后停止,不自动恢复。交还说明进入原任务的新模型段,保留工作标识及已经执行的工具结果。
+
+## 数据、更新与恢复
+
+- 控制面:账号、Agent、团队、目标、子任务、投递、产物与 Runner 注册信息。
+- Runner:私有配置、执行回执、Worker 会话、电脑租约和本机工作区。App 使用系统加密保存 Runner 配置。
+- 独立电脑持久文件和浏览器资料各自保留;可信共享电脑共享这些数据,但成员的模型会话历史独立。
+- 更新镜像先构建/导入、校验并启动验收电脑;空闲后才切换配置,保留旧镜像。
+- 工作区维护先取得 Runner 独占锁并确认停止。备份按文件校验;恢复暂存新数据,保留旧数据副本与恢复日志。保留挂载根目录以兼容 macOS 文件共享,中断后先回退,不能使用部分恢复结果。
+- App 使用整体替换的手动更新/回退流程;包含数据迁移时同时恢复更新前数据。App 内置服务不能调用源码部署更新器。
+
+## 启用顺序
+
+1. 安装或准备 Hermes、其 Python 环境及 Docker/Podman;App 内置 Node/Web/Runner,不把整个 Hermes Python 环境偷偷复制进包内。
+2. 打开 App,完成本地账号与 Hermes 来源配置。在执行节点设置中注册 Runner,指定允许的 Profile、工作目录和联网模式。
+3. 运行镜像准备工具并填入它返回的完整镜像 ID;通过 App 原生菜单导入私有 Runner 配置,确认节点上线。
+4. 新建使用隔离电脑的 Agent;需要自主组队时,仅给管理员开启管理团队权限。
+5. 在原会话提出目标,查看任务分派、验收和产物。需要帮助时从电脑面板接管;可信成员共享须另外由用户勾选授权。
+6. 远程服务器部署 Web 时,Runner 安装在实际执行主机上,向控制面主动连接。远程网络默认 HTTPS,机器凭据不等于用户登录凭据。
+
+## 交付与证据
+
+详细验收以 `agent-platform-implementation.md` 为准。当前已有真实 Docker / Hermes 模型循环 / 打包 Runner / 默认 Docker 控制面的执行与产物证据,Web 和两端原生电脑面板的 UI 往返证据。模型服务使用确定的本地测试响应;没有把脚本模型结果冒充为真实模型自主决策质量。
+
+正式服务部署、真实手机安装、系统重新登录、Podman 实机、amd64 镜像、公证和对外发布分别记录。开发签名的分支 App 不等于已公证的公开发行版。当前实现是容器级隔离,macOS 底层 Linux VM 由 Docker/Podman 提供;独占内核 VM、宿主 macOS CUA 和其他云电脑 Provider 是明确的扩展边界。
+
+配套说明:`bot-agent-teams.md`、`runner.md`、`computer-provider.md`、`computer-worker.md`、`computer-network.md`、`computer-control.md`、`task-helpers.md`、`shared-computers.md`、`computer-maintenance.md`、`macos-app.md`。
+
+
+2026-09-09 本地虚拟机的新流程以 `local-vm.md` 为准:准备与隔离策略在应用设置,电脑预览和实例操作在 Agent 聊天右侧;手机端仅从当前聊天查看与操作电脑。

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

@@ -0,0 +1,120 @@
+# 多 Agent、电脑环境与 macOS App 落地记录
+
+目标:完成本线程确认的整体方案。macOS App、Web、执行节点和电脑服务源码共同维护在 hermes-yaoyao;移动客户端继续使用各自仓库。当前工作分支为 `feat/bot-agent-teams`。
+
+本文用于保留完整范围和验收证据。2026-09-09 已完成下列分支实现及交付验收;历史段落中的阶段待办以本页最终记录为准。完整设计、参考项目取舍和启用顺序见 `agent-platform-design.md`。
+
+## 完整交付范围
+
+### 桌面与服务
+
+- [x] macOS App 复用 Vue 界面,打包所需服务运行时,打开 App 自动启动或连接正确的服务。
+- [x] 单实例、数据目录独占、身份/版本验证;兼容已有服务,不误停外部实例。
+- [x] 关闭窗口保留任务、菜单栏恢复窗口、明确退出时安全停止自己管理的进程。
+- [x] Hermes 检测与托管、服务启动进度、可恢复错误、日志、崩溃恢复与睡眠唤醒核对。
+- [x] 可选后台常驻与开机启动;正常安装不强制开启。
+- [x] 本机凭据保护、受限桌面通信、受信任页面与外部页面隔离。
+- [x] arm64 App/安装包、签名完整性、更新与回退流程;公证和发布状态独立核实。
+
+### 团队执行
+
+- [x] 基础自主创建 Agent、团队、启动和查询任务,以及按轮次授权(`ac62d07`)。
+- [x] 持久 Agent、临时助手、团队角色分离;记录创建来源与可恢复生命周期。
+- [x] 子任务负责人、依赖、验收条件、产物归属、结构化分派和计划调整。
+- [x] 成功/失败结果自动回到原会话,忙碌时排队;归档、停止、撤权后不复活旧任务。
+- [x] 区分执行结束与目标完成;复核、返工、等待用户、受阻状态可追踪。
+- [x] 持久投递与去重、重启修复、自动唤醒/递归/配额限制。
+- [x] Agent、桌面和共享写入资源的并发限制;取消只影响所属任务。
+
+### 执行节点与电脑
+
+- [x] Hermes 侧 Runner、版本/能力协商、受控工具传输,覆盖默认 Docker 部署与远程节点。
+- [x] 权限取账号、Agent、任务、节点、电脑能力的交集;模型不能指定真实调用身份或凭据。
+- [x] 电脑 Provider 统一接口与离线一致性测试;Docker、Podman和远程节点支持。
+- [x] 独立电脑环境与受控 Hermes Worker;终端、文件、浏览器和子进程不会退回宿主执行。
+- [x] 按需启动、资源上限、持久工作区、浏览器资料、产物导出和空闲清理。
+- [x] 画面查看/控制分离、人工接管/交还、递增租约版本、旧请求拒绝。
+- [x] 环境重建、数据恢复、更新维护、在途操作不确定性处理。
+- [x] 可选可信团队共享环境、多桌面、独立浏览器资料及一致保存。
+- [x] 扩展提供商适配边界,能力不足时明确降级,不伪装为同等隔离。
+
+### 跨端与验收
+
+- [x] Web、macOS、iOS、Android 共享权限/状态/事件协议,按平台提供入口和安全降级。
+- [x] 原始聊天展示回传结果、团队展示分派与产物、电脑面板支持查看与接管。
+- [x] 隔离环境下完成启动、重启、撤权、重复请求、并发、停止竞态和数据恢复测试。
+- [x] 真实打包 App 及真实电脑环境验收;脚本模型与真实模型证据分别记录。
+- [x] 所有实现以中文 Git 提交;正式服务部署、安装、公证和发布的状态明确区分。
+
+## 实施顺序
+
+1. 桌面托管与服务身份/生命周期基础。
+2. 持久团队分派、结果回传与资源调度。
+3. 执行节点及隔离电脑 Provider、Hermes Worker。
+4. 共享桌面、跨端、后台服务、更新恢复及最终交付验收。
+
+## 分阶段证据(保留历史状态)
+
+- 起点:Web `ac62d07`,iOS `5908a2b`;此前自主组队分支的验收见 `docs/bot-agent-teams.md`。
+- 桌面基础:开发启动与打包后 App 均通过 `desktop/lifecycle.test.mjs`,实际测试服务启动、窗口关闭后存活、强制结束服务后的恢复、外部连接不误停服务、退出清理。
+- 2026-09-08 首个桌面快照:arm64 App,开发证书签名;严格签名验证和 DMG 校验通过。DMG SHA-256:`611995fea080455da3a87a561cb5a7465a70e7437a91b948d45ce346822b0095`。这是阶段产物,尚未公证、发布或替换正式服务。
+- 桌面基础对应服务全量测试 652 项通过;任务协调器正在继续实现,不能据此宣称后续任务或 VM 已完成。
+- 任务协调阶段:类型检查、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 或真实模型自主决策验收。
+- 桌面 Runner 阶段:App 新增原生配置导入、系统加密存储、私有进程传参、自动启动与有界重连。开发 App 的 4 项生命周期/凭据测试通过;打包 arm64 App 的真实生命周期测试再次通过,覆盖配置导入、服务崩溃后的重新连接、窗口关闭保留连接、退出清理与再次打开自动连接。
+- 此次打包验收使用 `/tmp/yaoyao-runner-package-130917f/mac-arm64/夭夭.app`,为临时 ad-hoc 签名测试包,严格签名检查通过。未替换 `desktop-release` 中较早的开发证书快照,也未安装、部署、公证或发布。服务全量 680 项通过;配置与 Runner 定向 23 项通过。
+- 电脑 Provider 基础:实现私有容器、归属与约束校验、持久工作目录、guest 执行和取消时的停止确认。15 项 Docker/Podman 离线测试通过;真实 Docker 无网络环境的创建、画面读取、持久文件重建和取消写入验收通过,截图已核对,测试容器已清理。详见 `docs/computer-provider.md`。尚未接入任务资源租约和受控 Worker,VM 功能仍未完成。
+- 电脑租约基础:SQLite 保存持有者、递增代次、期限与停止状态;配额在启动前预占,未确认停止的资源保持占用,取消后退休旧容器 ID。资源池 6 项测试及接入资源池后的真实 Docker 生命周期测试通过。仍未将该资源池接入 Bot 任务和受控 Worker。
+- 当前源码验证:类型检查、711 项全量测试通过;2 项真实环境测试默认按需跳过,已分别执行过真实 Hermes Runner 协议和真实 Docker 电脑/租约验收。所有电脑验收容器均已清理。整体目标仍在进行,下一步是受控 Worker、联网代理及任务/权限接入,随后完成跨端电脑界面与其余交付项。
+
+- 受控 Worker 阶段:Bot 执行方式可选择隔离电脑,已接入真实 Hermes 模型循环、任务授权与持久电脑租约。模型循环在 Runner 侧,工具和工具子进程在 guest 内;凭据不传入电脑环境。详见 `docs/computer-worker.md`。
+- 完整 Runner 二进制验收经过控制面 → Runner → Hermes Worker → 真实 Docker;验证 UID 1000 执行、配置中的 cwd、同一 Agent 更新 cwd 后继续执行、宿主原生工具拒绝、团队创建、原会话结果、文件附件下载及多块二进制导出。模型服务使用确定的测试响应,没有调用付费模型。
+- 增加执行节点能力协商,旧 Runner 不支持隔离时明确拒绝;未提交的资源不足任务保留排队,恢复后只提交一次。历史消息记录原执行环境,后续改变 Agent 设置也不会把原隔离消息的路径交给宿主读取。
+- 产物校验后保留原作者/消息归属,支持原有下载接口、转交与任务复核附件。类型检查和 718 项测试通过;4 项真实环境测试默认跳过,Worker 的真实环境与打包 Runner 版本已单独执行。
+- 全目标仍未完成:优先继续受控联网、镜像准备/升级、临时助手、屏幕查看与人工接管、共享环境、移动端入口及后台/更新交付项。不能将 Worker 链路完成视作完整平台交付。
+- 本阶段最终检查:类型检查与 719 项全量测试通过,4 项真实环境测试默认按需跳过;打包 Runner 的真实 Worker 测试 2 项通过,包含原生宿主工具拒绝、分块二进制产物、下载、cwd 变更和取消延迟写入。Web 4 项入口测试及桌面 4 项生命周期测试通过;窄屏弹窗截图已核对。测试电脑容器全部清理。
+- 停止与撤权分开处理:撤销执行授权不阻断已绑定任务的清理,只允许清理原会话;清理通道不能提交新工作。电脑恢复首先核对并停止原持有者,旧容器身份不会被新任务复用。
+
+- 公网代理阶段:保持电脑无外部网卡,通过专属进程管道建立公网 80/443 连接,拒绝保留地址、内网、宿主网卡地址及混合 DNS 结果。支持命令环境变量与浏览器代理策略。真实 Docker 的 curl/Firefox HTTPS、截图、宿主拒绝和关停测试通过;完整打包 Runner 的 Worker 公网访问测试通过。详见 `docs/computer-network.md`。
+- 联网阶段最终验证:类型检查和 756 项全量测试通过,5 项真实环境测试默认跳过;地址/代理与 BFF 定向 50 项通过,Web 4 项流程测试通过。真实 Firefox 公网页面截图已核对;公网 Worker 的打包 Runner 验收通过。电脑验收容器全部清理,完整目标仍未完成。
+- 临时助手阶段:新增创建并分派任务的工具,助手没有单聊、不是永久团队成员,只能执行所属目标的当前阶段;固化隔离模式,禁止提权和跨任务使用。目标结束后退役,按原 Runner 进行持久清理重试;清理只删除助手执行数据,保留控制面产物。详见 `docs/task-helpers.md`。
+- Web、iOS、Android 已加入临时助手标识和持久成员选择过滤。iOS 27 项 Workspace 测试通过;Android 模型测试和 Debug Kotlin 编译通过。真实 Hermes Worker / Docker 的临时助手执行和退役清理验收已通过。
+- 临时助手最终验收:服务端类型检查与 759 项全量测试通过,5 项真实环境测试默认跳过;打包 Runner 的真实临时助手完成/清理验收通过;Web 4 项流程测试和临时助手标识截图通过。iOS 27 项 Workspace 测试、Android 31 项模型测试和 Debug Kotlin 编译通过。测试容器全部清理。其余镜像管理、电脑接管、共享环境和桌面交付项继续推进。
+
+- 电脑控制阶段:Web、macOS、iOS、Android 入口已接入只读画面与独立人工控制凭据;暂停模型、等待在途工具、同一电脑递增代次转交、交还后继续原任务。真实 Docker 终端点击/输入/文件证明和原任务续跑通过。详见 `docs/computer-control.md`。跨端原生视觉、镜像管理、共享环境和最终桌面交付仍待完成。
+- 电脑控制源码检查:类型检查和 Web 构建通过,762 项全量测试通过,5 项浏览器流程通过,iOS 28 项 Workspace 测试通过,Android 模型测试和 Kotlin 编译通过。窄屏电脑面板截图已核对。本轮验收容器全部清理,正式 Web 服务 PID 28292 未改变。
+
+- 镜像与维护阶段:本仓库提供固定来源的镜像配方、健康/截图验证、校验归档导入导出、停止后的工作区备份/恢复/重建及内容恢复中断后的回退。新镜像不会自动替换运行环境;保留旧镜像与恢复前数据。真实 arm64 Docker 构建、归档往返通过。详见 `docs/computer-maintenance.md`。
+- 维护阶段最终验证:类型检查和 767 项全量测试通过;真实新镜像的工作区恢复后重启读写通过;打包 Runner / 新镜像的完整 Worker 验收 2 项通过,包含公网、临时助手和人工接管。恢复保留挂载根目录以兼容 macOS 文件共享,并测试中断回退。
+
+- 桌面补齐阶段:内置服务版本与 App 对齐校验,已有外部服务显示实际版本;登录启动为原生显式开关,支持登录时仅驻留菜单栏。App 内置服务禁用源码部署更新器,提供 App 手动更新/数据回退说明。6 项桌面生命周期及凭据/偏好/版本测试、14 项更新器定向测试通过。真实开机与最后完整包验收仍需区分,见 `docs/macos-app.md`。
+
+- 可信共享阶段:用户显式授权同来源/Profile 的持久成员,新建共享工作区;电脑租约串行,成员模型历史独立,临时助手与模型组队工具不能自行加入共享。共享电脑固定原 Runner;人工控制按整个环境互斥。真实 Docker / 新镜像 / 打包 Worker 的跨成员文件和会话边界验收通过。见 `docs/shared-computers.md`。
+- Web 6 项流程通过;iOS、Android 原生电脑面板的只读/接管/输入/交还 UI 与 HTTP 往返均通过,截图已核对。iOS 28 项 Workspace 测试、Android 模型与 Debug 编译通过。最终完整 App 打包、默认 Docker 控制面验收和清单核对继续推进。
+- 共享阶段最终源码验证:770 项全量测试通过;旧 Runner 能力拒绝保留原错误契约,子账号隔离用例定向 20 项及最终全量均通过。共享电脑 UI 6 项通过,真实共享 Worker 2 项通过。镜像配方和许可证纳入默认 Docker 构建输入。
+
+## 最终交付(2026-09-09)
+
+- App 代码来源:`5455a98`;包内 `build-info.json` 同时记录运行时代码 `a5d4ad4` 与原生助手最低 macOS 12.0。最后一次原生助手按最低系统版本重编译后,重新签名 App、重跑实际签名 App 测试,再生成 DMG。
+- Apple Silicon App:`desktop-release/agent-platform/mac-arm64/夭夭.app`。
+- 安装包:`desktop-release/agent-platform/Yaoyao-0.3.32-arm64.dmg`,SHA-256 `3f86be3315ff9da3e573f67f49c1d6ea0a29f15f5a3172647aaf4ea2b31cc3a7`。
+- 离线电脑镜像:`desktop-release/agent-platform/Yaoyao-Computer-arm64.tar`,SHA-256 `63a72afff1169439d74b500b883d8cff4bbbf726148c21a64226d8d3b02f2f7e`;同目录包含镜像清单、校验文件和启用说明。
+- 类型检查、生产 Web 构建通过;最终服务测试 770 项通过,6 项需明确环境的真实验收默认跳过。真实 Hermes/Runner、Docker 电脑、联网、共享、人工接管、工作区恢复和默认 Docker 控制面已分阶段单独执行。
+- Web 6 项流程、iOS 28 项 Workspace 单元测试及 1 项电脑 UI/HTTP 流程、Android 31 项模型测试、Debug 编译及 1 项电脑 UI/HTTP 流程通过,截图已核对。
+- 桌面 8 项测试通过。最终实际签名 App 的核心生命周期和完整 Runner 配置/加密/重启/退出两项验收通过;新密钥流程没有修改旧钥匙串项目权限。
+- App、内嵌助手及 DMG 内 App 的严格签名校验通过;DMG 完整性校验、挂载后的助手及构建信息一致性检查通过。分发包不包含仅用于开发的加密助手。
+- Docker 控制面镜像 `yaoyao-agent-platform:branch-arm64`,镜像 ID `sha256:632c1440cfd3abbd7450678bd690eea1f1efa7be9a0bec68e4a0d8a4914fd8e8`。按默认只读、UID 1000、无额外 capability、独立数据卷配置,连接容器外 Runner 并完成隔离任务及文件回传。
+
+### 交付边界
+
+电脑采用容器级隔离,macOS 底层 Linux VM 由 Docker/Podman 提供;多桌面指多台独立电脑,共享电脑为单桌面串行,浏览器资料也共享。独立电脑各自保存浏览器资料。备份是确认停止后的文件一致备份,不是内存/应用事务快照。
+
+Docker 已做真实运行验收;Podman 有接口与离线一致性测试,未做实机运行。远程节点使用相同的主动连接协议,Docker 控制面与容器外 Runner 已验收;没有宣称不同物理主机、全部操作系统版本均已实测。登录启动开关有实现和偏好验证,本轮没有重新登录 macOS。
+
+模型循环使用真实 Hermes SDK,模型输出由本地确定性测试服务提供,未调用付费模型。正式 15300 服务、实体手机、参考仓库没有被本轮替换或部署;验收容器、临时数据卷和测试服务均已清理。App 使用 Apple Development 开发签名,未公证、未对外发布。旧 safeStorage 配置若需要钥匙串授权,仍由用户完成正常系统授权。
+
+
+2026-09-09 本地虚拟机的新流程以 `local-vm.md` 为准:准备与隔离策略在应用设置,电脑预览和实例操作在 Agent 聊天右侧;手机端仅从当前聊天查看与操作电脑。

+ 54 - 0
docs/app-web-sync.md

@@ -0,0 +1,54 @@
+# App 与 Web 版本同步
+
+正式 macOS App 在安装或更新后的首次启动同步本机 Web。只有复制 App、尚未打开时,不执行安装脚本。手机和远程服务器不参与这个过程。
+
+## 版本规则
+
+| 当前 Web | App 行为 |
+| --- | --- |
+| 尚未安装 | 使用 App 携带的预编译服务和 Node 安装独立后台服务 |
+| 版本较旧,或同版本且提交是 App 构建的祖先 | 等待空闲后向上同步 |
+| 同一构建/同一干净提交 | 直接连接 |
+| 发布版本或同版本提交更新 | 保留当前 Web |
+| 同版本分支关系不明、无法识别的工作区快照 | 保留服务并说明无法自动判断 |
+
+`build-info.json` 包含提交、构建序号、祖先提交、工作区状态和构建时间;构建序号仅用于展示,提交的先后必须有祖先关系,不能比较哈希字典序或文件时间。`runtime-manifest.json` 对完整服务包逐文件计算 SHA-256,并拒绝额外文件、丢失文件和符号链接。App 内置 Node 保持原签名,避免签名后改变已封存的服务包。许可证位于 `web-service/NODE-LICENSE.txt`。
+
+成功后在数据目录的 `updates/desktop-sync.json` 记录本次 App 服务包校验值。再次打开同一构建不会重新同步,允许 Web 继续自行升级或经兼容性检查后单独回滚。该记录属于数据目录,不是全局 App 偏好。失败不写成功记录,启动页可重试。
+
+## 统一后台服务
+
+App 同步和 Web 的更新/回滚共用 `bin/lib/service-update.mjs` 的事务、发布目录、SQLite 更新互斥和原有 `active.lock`。App 同步使用包内服务,不下载或现场运行 npm 构建;Web 的更新入口仍按发布源准备新版本。两条路径都切换同一个 `current`。
+
+首次接管时核对当前用户的 LaunchAgent、数据目录、端口、监听进程、SQLite 持有者和启动入口。已有地址、TLS、上游、通知凭据文件及其他环境配置保留。不接管未知监听进程、其他数据目录或远程服务器;TLS 身份无法正常验证时在停止前退出。
+
+新服务通过仅限本机、无 Origin、私有随机凭据的接口协商暂停。只有没有进行中的普通聊天、团队运行、Runner 操作及 HTTP 写请求时才暂停新请求;正在运行的任务继续完成。暂停租约在没有持久更新事务时 15 秒过期,避免更新器在切换前退出导致永久暂停。
+
+旧版没有暂停协议时,检查持久任务、普通聊天未完成事件和本机服务进程的活动连接。App 发现服务忙碌时立即延后同步,核验原服务身份后继续进入现有 Web;菜单显示“Web 同步待完成”,并提供“重试同步本机 Web”。不写同步成功记录。Web 自身的更新器仍最多等待一分钟。旧客户端关闭后可重试,不强行打断任务。
+
+## 数据与恢复
+
+1. 在公共更新锁内准备并校验完整服务包,确认原服务可访问。
+2. 等待空闲、暂停新请求,持久记录更新事务。
+3. 停止原服务并确认进程、监听和数据库句柄释放;备份 Web 数据及文件。
+4. 原子切换程序目录,启动新版本。验证完成前,新服务拒绝业务请求。
+5. 用私有实例信息核对 PID、数据目录、实际提交及服务包校验值,并检查 `/healthz`、`/api/status`。
+6. 验证成功后记录回退信息、解除暂停,最后记录 App 已同步。
+
+启动或迁移失败时,恢复原程序、数据库结构、数据文件和配置。备份排除 `updates`、桌面浏览器数据、独立 Runner 状态及进程锁;这些内容不会被 Web 回退覆盖。中断事务由下一次 App 同步或更新器运行先行恢复。已经恢复数据但尚未启动成功的事务不会重复用旧备份覆盖数据。
+
+成功升级后的手动回滚保留现有消息,要求升级前后及当前数据库结构一致。涉及结构变化或没有旧兼容性记录时拒绝直接降级,必须使用独立的数据恢复流程。失败更新期间的自动恢复只发生在业务请求尚未重新开放之前。
+
+退出 App 后独立 Web 继续运行。App 自己托管的 Runner 仍按原生命周期退出;若需要退出 App 后仍执行电脑任务,应使用独立 Runner。源码开发模式默认不接管正式服务,仍使用隔离的 App 子进程。
+
+## 验证入口
+
+- `tests/server/desktopSync.test.ts`:构建选择、包篡改、互斥、忙碌延后、一次同步、数据与结构恢复、中断恢复及降级兼容性。
+- `tests/server/serviceInstance.test.ts`:本机权限、空闲协商、更新期间拒绝写入、事务提交后恢复。
+- `desktop/service-sync.test.mjs`:实际 Electron App、包内 Node 和隔离 LaunchAgent 的安装、健康/构建核对、Web 更新入口、退出后继续运行及保留更新的 Web。
+
+原生验收使用临时数据目录、端口、发布目录和由目录哈希派生的测试 LaunchAgent 标签;清理只作用于该测试服务,不替换正式 15300 服务。
+
+启动页的 HTML、JavaScript、CSS 与 preload 都是必需打包资源,构建时逐一核验。`desktop/startup-feedback.test.mjs` 在真实 App 中制造同步助手失败,确认错误文本与“重试 / 查看日志”可见,再解除测试阻塞并点击重试进入 Web。只有 `service_busy` 会允许连接已验证的原服务;包校验、身份核对及其他错误继续显示在启动页。
+
+2026-09-09 验证:类型检查和生产/桌面构建通过;全量测试 787 项通过、6 项按环境跳过。原有 8 项桌面测试通过,新增真实 App/LaunchAgent 同步流程通过,覆盖退出后服务保留、较新 Web 保留、旧配置保留以及服务停止后再次打开 App 恢复独立服务。开发签名 App 的严格签名、服务包完整性及实际同步流程均已验证。此次修改不包含替换本机已安装 App 或正式 Web 服务。

+ 78 - 0
docs/bot-agent-teams.md

@@ -0,0 +1,78 @@
+# Bot Agent 自主组建团队
+
+分支:`feat/bot-agent-teams`。配套 iOS 使用同名分支;该功能没有修改基础 Hermes Profile。
+
+## 使用
+
+在 Bot 模式创建或编辑 Agent,打开「允许组建团队」并保存。例如给它发送:
+
+> 帮我完成项目调研,需要哪些成员就先查看已有 Agent,缺少时再创建。组建团队、启动任务,并告诉我到哪个团队查看结果。
+
+Agent 可以查看可用基础 Agent、复用已有成员、创建持久成员和团队、启动独立团队任务、读取进展和结果。新团队自动加入发起者并将其设为管理员,随后使用已有的 @分派和管理员复核机制。成员、团队和任务写入当前账号的 `workspace.sqlite3`,发布既有变更事件供 Web/iOS 同步。
+
+启动任务返回「已启动,尚未完成」。工作在团队任务内继续。子任务执行结束后,结果自动回到原会话并唤醒管理员复核;可通过「打开任务」进入具体任务。执行结束与目标完成分开记录,完成必须满足子任务验收和目标检查。
+
+## 运行条件
+
+- Web 服务和发起者的 Hermes 必须在同一机器/网络命名空间,以回环地址连接。远程节点和远端引用 Agent 可作为成员,不能开启发起组队权限。
+- 发起者的基础 Profile 需安装并启用既有 `yaoyao-bot-bridge` 工具桥,能力响应必须包含 `version: 1`、`ready: true`、`in_process: true`、`native_tools: true`。插件由 Hermes Bot 提供;按其安装流程部署并重新加载,Named Profile 需要各自安装、启用。
+- 服务端在首次授予权限及每轮执行前检查条件。不满足时返回具体错误,普通 Agent 对话仍不查询工具桥。
+- 先部署 Web 分支,再使用配套 iOS 分支。现有记录缺少 `canManageTeam` 时按关闭处理;新建成员默认关闭。
+
+## 工具与权限
+
+| 工具 | 作用 |
+| --- | --- |
+| `workspace_list_sources` | 当前账号获准的基础 Profile |
+| `workspace_list_agents` | 当前账号可使用的已有成员 |
+| `workspace_list_teams` | 发起者担任管理员的团队 |
+| `workspace_create_agent` | 创建成员,禁止授予组队权限 |
+| `workspace_create_team` | 创建管理员协作模式的团队 |
+| `workspace_start_team_task` | 在发起者管理的团队启动任务 |
+| `workspace_get_team_task` | 读取状态、最近 20 条消息和待处理问题 |
+| `workspace_assign_task` | 分派具有负责人、依赖和验收条件的子任务 |
+| `workspace_update_assignment` / `workspace_cancel_assignment` | 调整或停止子任务,保留原因与历史 |
+| `workspace_review_assignment` | 验收、明确返工或记录受阻 |
+| `workspace_finish_team_task` / `workspace_resume_team_task` | 记录目标结论,或从用户新指令恢复目标 |
+| `workspace_update_created_agent` / `workspace_archive_created_agent` | 管理自己创建且符合使用状态限制的成员 |
+| `workspace_update_team` | 调整空闲团队的规则、成员或归档状态 |
+
+所有变更工具要求 `requestId`(UUID);重试同一操作必须复用它。服务端持久保存回执;相同编号用于不同参数返回冲突,重复请求不会重复创建。原生工具调用 ID 也在本轮去重。
+
+账号和调用 Agent 从正在运行的服务端轮次取得,模型参数不能指定 owner、token 或调用身份。创建成员只能引用当前账号获准的基础 Profile;子账号的分配规则保持有效。只有团队管理员可以启动或读取该团队的任务,禁止从当前团队任务递归启动自身任务。
+
+每轮使用独立随机凭据、独立原生工具 ID 和回环监听端口,凭据仅传给经过认证的工具桥绑定接口。权限、账号、Profile、成员归属、归档和停止状态在每次调用时校验,异步操作在写入前再次校验。绑定保留 Agent 的配置版本;关闭后重新开启权限也不能使旧轮次重新生效。完成、取消、连接结束或服务关闭时销毁监听器并解绑;最长 30 分钟,运行时每 5 分钟续租。断线恢复只核对原执行,不重新提交已有工具副作用;下一轮重新授权。
+
+自动组队的范围:每队 2–8 人,最多管理 20 个有效团队,账号最多保留 100 个有效 Agent,每轮最多 128 次团队工具调用。结构化目标最多 32 个子任务,每次授权默认允许子任务执行 3 次、目标自动唤醒 8 次。恢复旧目标需要用户新指令,并确认旧执行已结束。同一持久 Agent 跨会话和任务串行执行;不同 Agent 保留全局最多 4 个执行槽。用户指令优先于后台分派,排队超过 60 秒的工作会提升优先级。
+
+停止与停止完成分别表示为 `cancelling`、`cancelled`,状态不确定时不重放副作用。结果投递、复核轮次和恢复代次都持久保存并去重。账号授权版本或组队权限变化使旧目标和工具授权失效。
+
+Web、iOS、Android 均可查看任务分工、验收和结果,停止或继续当前目标。Web 的任务选择会保留各自的文字、引用及附件草稿。macOS App 与 Web 共用界面,桌面托管实现位于 `desktop/`。
+
+## 验证
+
+```sh
+npm run typecheck
+npm test
+npm run test:e2e:team-tools
+```
+
+`tests/server/workspaceTeamTools.test.ts` 验证创建、持久去重、账号隔离、Profile 撤销、配置版本失效、停止、回环认证、不同轮次的工具 ID/凭据隔离、过期和取消绑定竞态。
+
+真实 Hermes 协议验收使用隔离测试实例(不调用付费模型、不访问用户会话):
+
+1. 使用 Hermes 的 Python,运行 Hermes Bot 仓库内 `integrations/hermes-bots-bridge/tests/hermes_wire_fixture.py /path/to/hermes-agent`。
+2. 将它打印的回环 URL 设为 `HERMES_TEAM_WIRE_URL`,运行 `npm test -- tests/live/workspaceTeamTools.live.test.ts`。
+
+此测试必须校验实例的专用 fixture 标记,实际经过 HTTP 登录、WebSocket 会话、插件原生工具注册、回环工具调用、持久成员/团队/任务和权限撤销。未设置 URL 时跳过该项;模型决策使用确定脚本,因此不代表真实模型在任意任务下的自主组队质量。
+
+### 2026-09-08 分支验收
+
+- 类型检查及生产构建通过。
+- 完整 Vitest:98 个文件、649 项通过;上述需显式指定实例的用例默认跳过,并已单独执行通过。
+- 真实 Hermes 隔离实例通过创建、重试去重、团队任务、撤销和两个发起者同时调用的验证,实际注册的原生函数名称互不冲突。
+- Playwright:新开关保存/重载/撤销 1 项、原有成员/团队及子账号权限回归 2 项通过。检查了桌面浅色与 375px 手机宽度深色截图。
+- iOS:24 项 WorkspaceChatTests 通过;开关保存往返在普通显示与深色大字体下均通过模拟器验收。
+- 首次全量并发运行有 1 项既有语音配置用例失败;聚焦重跑及最终全量运行均通过,未修改语音功能。
+
+所有结果来自分支代码和隔离数据。没有部署正式 Web、调用付费模型、安装实体手机或合并主分支。

+ 29 - 0
docs/computer-control.md

@@ -0,0 +1,29 @@
+# 电脑查看与人工接管
+
+Web 和 macOS 共用电脑面板;iOS、Android 使用相同的账号 API。只对当前会话成员和当前目标的临时助手显示电脑入口,旧服务不声明 `computerControl` 时不显示入口。模型没有人工接管工具,也不接收控制凭据。
+
+## 行为
+
+1. 打开面板先查看画面,不获得输入权。只读截图走独立通道,不排在长时间终端命令之后。
+2. 用户明确点击接管后,控制面签发限时控制凭据。Runner 暂停模型进程并等待已经提交的工具结束;这期间显示“正在等待当前操作结束”,输入保持禁用。
+3. 原电脑确认没有在途工具后,以递增的租约代次转交给人工;保留同一容器、桌面应用和工作区。已经启动的浏览器等后台应用继续运行。
+4. 输入必须携带本页面控制凭据、当前租约代次及最近画面 ID。坐标越界、旧画面、旧代次和其他页面的请求会被拒绝。重复操作 ID 不会再次输入。
+5. 交还说明与现有会话、工具结果一起传入新的模型段,保留原工作标识,继续原任务。交还是模型的续跑段,不是复用已终止的模型进程。
+6. 无任务时可直接打开电脑。结束该控制会话会停止电脑并保留持久工作区。
+
+## 权限与故障
+
+控制凭据仅由接管接口返回,不出现在状态接口;服务端保存其散列及加密副本,用于同一请求的安全重试。有效期为 30 秒,前台页面约每 8 秒续期。Runner 每 5 秒检查授权;到期、账号撤权、Agent 退役后停止所属工作,不自动恢复。移动端进入后台和 Web 页面隐藏后停止续期。再次操作需要新接管。
+
+每次操作仍经过账号、Agent 来源、Runner 能力、授权版本和环境归属检查。模型无法选择真实账号、Runner、环境 ID 或读取人工凭据。Runner 的控制能力为 `computer-control-v1`,不支持时明确拒绝,不回退宿主电脑。
+
+接管请求编号在响应不确定时保留,允许同一页面重试;其他页面等待原会话结束或到期。模型已提交但没有回执的工具,会在续跑上下文中注明结果不确定,不能假装未执行。停止未确认时保留资源占用,不能交给下一使用者。
+
+当前交互为定期 PNG 画面、点击、文字和常用按键;不宣称视频串流、音频或完整系统远程桌面。CUA 接受输入只能证明命令已送达,界面效果由下一帧确认。
+
+## 验证
+
+- 控制面测试:只读状态不返回凭据、其他账号/页面拒绝、令牌和授权版本校验、同一接管请求重试。
+- Runner 测试:查看不调用输入、代次/画面/坐标校验、输入去重、撤权停止、原电脑租约转交。
+- 真实 Docker + 打包 Runner + Hermes 模型循环:执行中接管、原任务转为等待、真实截图、点击打开终端、输入命令生成文件、错误令牌拒绝、交还后原任务完成。模型响应来自本地确定性测试服务,未调用付费模型。
+- Web 浏览器测试覆盖只读、接管、输入、交还和窄屏;iOS 28 项 Workspace 测试通过,包含授权/画面代次和撤权清理;Android 模型测试与 Debug Kotlin 编译通过。iOS 和 Android 的原生电脑面板已通过模拟器 UI/HTTP 往返,实际截图已核对,见 `docs/shared-computers.md`。

+ 50 - 0
docs/computer-maintenance.md

@@ -0,0 +1,50 @@
+# 电脑镜像与工作区维护
+
+镜像配方和维护工具由本仓库交付,不需要访问 OpenMausBot 的本地目录。`deploy/computer/Dockerfile` 从固定摘要的 CUA XFCE 基础镜像构建,驱动、字体和许可证下载均校验 SHA-256;保留兼容的 CUA 路径与标签,增加夭夭镜像标签及 Firefox 持久资料。派生配方的 Apache-2.0 许可和来源在 `third-party/openmausbot`。
+
+## 准备与离线搬运
+
+先运行 `npm run runner:build`。以下命令也在 macOS App 的 `Contents/Resources/runtime` 内提供,可用 Node.js 24 执行:
+
+```sh
+node .runner-build/runner-image.mjs prepare --runtime docker --output /安全目录/computer-image.json
+node .runner-build/runner-image.mjs inspect --image sha256:完整镜像ID
+node .runner-build/runner-image.mjs export --image sha256:完整镜像ID --archive /安全目录/computer.tar
+node .runner-build/runner-image.mjs import --archive /安全目录/computer.tar
+```
+
+`--runtime podman` 使用相同流程。Docker/Podman 必须指向本机运行时;远程主机在当地运行 Runner 和准备工具。缺少运行时或驱动未就绪会报错,不创建宿主执行替代路径。
+
+准备与导入都会创建一次独立验收电脑,等待驱动、读取健康状态和真实截图,随后清理验收容器。导出的 `.tar.json` 记录完整镜像 ID、架构、协议、驱动和归档 SHA-256;导入先验证归档,再核对实际镜像身份及架构。导出不覆盖现有归档。工具不会自动修改 Runner 配置,也不会替换运行中的电脑。
+
+更新时先准备并验证新镜像,停止 Runner 后把配置的 `computers.imageId` 改成新的完整 ID,再启动。资源池只在环境空闲时应用新配置,保留工作区;旧镜像不自动删除。回退使用原镜像 ID,必要时恢复升级前的工作区备份。浏览器资料格式也可能升级,因此不能以镜像回退代替数据回退。
+
+## 工作区备份、恢复与重建
+
+必须先停止该 Runner。维护工具获取与 Runner 相同的数据目录独占锁,不能和正在执行的 Agent 或人工接管并行运行。它首先处理崩溃遗留的租约和容器;未确认停止的环境不能维护。
+
+```sh
+node .runner-build/runner-maintenance.mjs list --config /安全目录/runner.json
+node .runner-build/runner-maintenance.mjs backup --config /安全目录/runner.json --environment UUID --snapshot /备份目录/本次备份
+node .runner-build/runner-maintenance.mjs restore --config /安全目录/runner.json --environment UUID --snapshot /备份目录/本次备份
+node .runner-build/runner-maintenance.mjs rebuild --config /安全目录/runner.json --environment UUID
+```
+
+CLI 默认数据目录为配置旁的 `state/<runnerId>`。App 托管节点的数据目录是 `~/.hermes-yaoyao/runner-state/<runnerId>`,维护时用 `--home` 显式指定该目录,并使用原始私有配置文件;App 加密配置不能直接当作 JSON 读取。
+
+备份包含工作文件及持久浏览器资料,不包含运行中的内存、进程、模型密钥、Runner 身份或整个宿主 HOME。目录设为仅当前用户访问,浏览器资料仍可能包含登录状态,应作为私有数据保存。每份备份有账号/环境归属和逐文件校验;普通文件、目录、符号链接保留,不复制运行时 socket。上限为 20 GiB、20 万项。
+
+恢复先验证完整内容并复制到暂存目录;恢复前的数据经校验保留为 `.before-restore-*`,不自动删除。保留挂载根目录并替换其内容,以兼容 macOS 容器共享文件系统。恢复前写入日志,进程若在恢复中退出,下一次启动先从旧数据副本回退,不使用部分恢复的工作区。重建只退休已停止的容器身份,下次启动从配置镜像重新创建,保留工作区;它不是清空数据。
+
+这是停止电脑后的文件一致备份。浏览器或数据库在强制中断前未落盘的内容不在备份中,不能宣称应用事务快照。
+
+## 验证状态
+
+2026-09-09 在 arm64 Docker 上从本仓库构建、健康/截图检查、归档导出、SHA-256 校验后重新导入通过。验证镜像:`sha256:befa37db3c64fa231237d6898d9403029ca9f02a01beb41ff1cf0de113130f55`。归档:`/tmp/yaoyao-computer-0909.tar`,SHA-256 为 `63a72afff1169439d74b500b883d8cff4bbbf726148c21a64226d8d3b02f2f7e`。
+
+离线测试覆盖驱动/身份/架构不符、损坏归档不调用运行时、拒绝覆盖、在用环境拒绝维护、恢复前校验、保留旧数据及内容恢复中途崩溃回退。Podman 参数一致性有离线证据,真实 Podman 与 amd64 构建尚未验收。
+
+真实电脑备份/恢复后重新启动并读取原文件通过,恢复前文件保留在旧数据副本。新镜像的打包 Runner / Hermes Worker 验收 2 项通过,包含公网访问、临时助手、产物回传、人工接管和原任务续跑。类型检查及 767 项全量测试通过。
+
+
+2026-09-09 本地虚拟机的新流程以 `local-vm.md` 为准:准备与隔离策略在应用设置,电脑预览和实例操作在 Agent 聊天右侧;手机端仅从当前聊天查看与操作电脑。

+ 35 - 0
docs/computer-network.md

@@ -0,0 +1,35 @@
+# 隔离电脑的公网代理
+
+Runner 可为隔离电脑启用 `public-proxy` 网络策略。现有配置未填写策略时仍为 `none`,不会自动扩大联网权限。
+
+电脑继续使用 `--network none`,不发布网络端口。guest 内的 HTTP 代理只监听 `127.0.0.1:3128`,通过该电脑专属的进程管道,把连接请求交给 Runner。公网 TCP 连接由 Runner 建立,HTTPS 内容保持端到端 TLS,代理不添加宿主 Cookie、模型密钥或账号凭据。
+
+## 地址与授权边界
+
+- 只允许公网 HTTP/HTTPS 的 80、443 端口。
+- 按 [IANA IPv4 特殊用途注册表](https://www.iana.org/assignments/iana-ipv4-special-registry/) 和 [IPv6 特殊用途注册表](https://www.iana.org/assignments/iana-ipv6-special-registry/) 保守拒绝私有、回环、链路本地、共享地址、文档和转换等特殊用途范围;IPv6 只接受普通全局单播范围。
+- 拒绝 Runner 本机网卡地址、含凭据的地址和不合法 authority;非常规 IPv4 写法先规范化再检查。
+- 每个新连接重新解析 DNS,任何一个解析结果不符合策略则拒绝。实际连接固定到已检查的 IP,后续 DNS 变化不能替换目标。
+- 建立连接前后、传输期间和周期检查都核验任务租约;创建连接时另外检查控制面授权。停止任务会关闭该电脑的代理和连接。
+- 每个代理最多 16 个外部连接、256 MiB 双向流量,有队列大小和空闲超时限制。拒绝一个目标不会为它打开任何宿主 socket。
+
+这是一项保守的公网网页能力,不是通用 VPN、SSH、内网代理或无限流量通道。关闭/移除代理设置只会失去外网连接,不会获得直接访问宿主或内网的能力。
+
+## 客户端配置
+
+电脑创建时设置 HTTP/HTTPS 代理环境变量,供 curl、Python 等命令使用。Runner 在 guest 内配置 Chromium/Chrome 和 Firefox 的代理策略,浏览器仍可访问自己电脑的 loopback 服务。直接连接外部地址仍受无网络命名空间限制。
+
+Web 的执行节点配置新增“联网范围”:
+
+- 公网网页(HTTP / HTTPS)
+- 不联网
+
+该选择保存在节点的私有配置中;Agent 的执行环境仍需选择“隔离电脑”。
+
+## 验收
+
+- `tests/server/publicNetwork.test.ts` 验证地址范围、非常规 IP 写法、DNS 混合结果和重绑定、本机地址、异步撤权、连接配额及真实 socket 字节传输。
+- `tests/live/network.live.test.ts` 在新建的真实 Docker 电脑中验证仅存在 loopback 网卡,经代理读取 HTTPS 页面、Firefox 打开页面并产生截图、拒绝宿主地址、关闭代理后外网不可达。截图保存在 `test-results/network/browser.png`。
+- 启用 `YAOYAO_WORKER_PUBLIC_NETWORK=1` 的 Worker 验收经过完整控制面与打包 Runner,再由真实 guest 访问公网。
+
+联网仍依赖执行节点自身能连接目标公网服务。网络代理不改变电脑工具、文件和任务的归属;本轮测试只使用隔离数据与公开示例页面。

+ 34 - 0
docs/computer-provider.md

@@ -0,0 +1,34 @@
+# 私有电脑 Provider 基础
+
+实现位于 `src/runner/computers/container.ts`,提供统一的创建、查询、执行、停止和删除接口。每个环境有独立 UUID、账号归属键、Runner 身份、容器和持久工作目录。`pool.ts` 提供持久电脑租约与资源配额。现在已通过受控 Worker 接入 Bot 任务,详见 `docs/computer-worker.md`。本文记录 Provider 和资源租约本身的边界。
+
+## 已实现的约束
+
+- Docker 和 Podman 使用同一份接口与配置校验;远程引擎要求将 Runner 放到目的电脑,避免把本机目录误当作远程挂载路径。
+- 镜像必须使用完整不可变 ID,并验证兼容的 CUA 驱动标签。当前采用本机已有的 OpenMausBot CUA 0.20.0、layer 5 镜像接口;没有复制或修改参考仓库。
+- 环境运行在容器隔离层。macOS 上 Docker/Podman 的底层 Linux 虚拟机由运行时提供,每个环境不是一台独占内核的 VM。
+- 仅挂载该环境的持久工作目录;不挂载 Docker socket、宿主 HOME、Hermes 配置或凭据。命令固定以 guest UID/GID 1000 执行,工作目录为 `/home/cua/workspace`。
+- 固定独立 IPC/cgroup 命名空间、禁止提权、收紧 capability,并设置 CPU、内存、swap 与进程数上限。每次执行前核对运行实例、镜像、归属、挂载与约束。
+- 默认 `--network none`,不发布任何端口。容器只有 loopback 网卡,不能直接访问宿主服务或外网。自己的主机名映射到 loopback,保证 XFCE/VNC 在无网络环境下正常启动。可通过专属进程通道启用受控公网代理,仍不增加外部网卡或端口。详见 `docs/computer-network.md`。
+- 桌面通过容器内的 CUA socket 操作;验收已读取真实截图。查看和人工接管已接入账号鉴权及独立控制租约,见 `docs/computer-control.md`。
+- 取消或无法确认的 guest 命令会停止所属私有容器,确认停止后才释放串行执行位置。仅终止 `docker exec` 客户端不能证明 guest 进程已停止。
+- 删除容器保留工作目录,重建后可继续读取工作文件。停止后的工作区备份、校验恢复和重建见 `docs/computer-maintenance.md`;可信共享电脑见 `docs/shared-computers.md`。
+
+权限校验以回调传入,在异步检查与实际执行的前后重验。ComputerPool 在 SQLite 中保存持有者、租约代次、期限与停止状态,默认每个 Runner 同时 2 个环境、总计最多 32 个环境。启动时必须先恢复未确认停止的资源;旧租约先失效,再停止并删除旧容器身份,保留工作区。不确认停止的环境保持占用,不能交给新任务。当前 Provider 与资源池都不作为裸命令接口对浏览器或模型开放,通过 Worker 和任务授权检查使用。
+
+## 参考与差异
+
+吸收 OpenMausBot `server/container-computer.ts` 的不可变镜像、每 Bot 环境、持久工作目录、能力检查和实际健康/画面验证;默认网络进一步收紧为无网络环境。吸收 rakazo 的屏幕租约和接管机制,在任务协调层加入持久 fencing 代次,先确认旧执行停止,再允许新控制者使用资源。
+
+## 验收状态
+
+Provider 的 15 项与 ComputerPool 的 6 项离线测试通过,覆盖 Docker/Podman 参数边界、账号/Runner 归属、挂载/网络/权限/资源篡改、异步撤权、危险目录、配额、取消、重启恢复及过期扫描不误停新的持有者。Podman 尚未做真实运行验收。
+
+真实 Docker 验收通过:创建独立电脑、验证只有 loopback 网卡、guest UID 1000 写入文件、读取 CUA 健康报告和真实桌面截图、删除重建后读取持久文件、通过 ComputerPool 取消延迟写入,确认旧容器被删除,拒绝旧租约继续执行。截图在 `test-results/computer/desktop.png`。每次测试均使用新的 UUID 和临时目录,并清理本轮容器。
+
+```sh
+YAOYAO_COMPUTER_IMAGE=sha256:本机已验证镜像的完整ID \
+  npx vitest run tests/live/computer.live.test.ts
+```
+
+本轮 arm64 验证镜像为 `sha256:574b51992c53e22d48997d4286ba83e47b872de34250276c99d2f390bf3640e0`。镜像构建与跨端电脑面板已有实现,见对应文档;可信共享环境见 `docs/shared-computers.md`,不能以本测试替代整个 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 入口打包位置及启动方式。
+
+测试模型服务使用确定的协议响应,没有调用付费模型。这些证据证明执行链路与边界,不代表真实模型在任意复杂任务下的自主决策质量。
+
+## 仍需完成
+
+电脑可按节点配置使用受控公网代理,详见 `docs/computer-network.md`。镜像安装/更新、临时助手、屏幕查看/接管、可信团队共享环境,以及移动端执行方式设置等仍属于完整目标的后续工作。现有移动端已可使用服务端创建的隔离 Agent、接收结果和文件,但不能据此宣称完整电脑界面已交付。

+ 15 - 0
docs/desktop-lifecycle.md

@@ -0,0 +1,15 @@
+# macOS App 与后台服务
+
+| 操作 | 结果 |
+| --- | --- |
+| 关闭窗口 | 隐藏窗口,App 与后台继续运行 |
+| ⌘Q / 退出夭夭(后台继续运行) | 一次退出 App,独立 Web、本机托管 Runner 与虚拟机继续按原生命周期运行 |
+| 停止后台服务并退出 | 停止本应用管理的后台服务、任务与虚拟机,再退出 App;保留工作文件和配置 |
+
+重新打开 App 会核对已有后台的身份和健康状态;后台运行时连接现有实例,已停止时重新启动。退出期间不切回启动页,也不接受重复打开/退出请求。应用菜单可查看“后台服务运行中”。只有确认服务停止才显示“后台服务已停止”;断开连接单独表示为“App 已断开后台连接”。
+
+普通退出仍会停止由 App 自身启动的高级外部 Runner;“本地虚拟机”使用的托管 Runner 在独立 Web 内运行,不受普通退出影响。服务停止只针对已核对数据目录、端口、进程和 LaunchAgent 的本机实例,不停止用户另外启动的 Hermes、Docker 或其他服务。
+
+停止操作使用与升级相同的互斥保护,更新进行中会拒绝停止。通过 `launchctl bootout` 卸载服务,避免 KeepAlive 重启;启动配置保留,下次打开 App 可重新启动。后台停止期间拒绝新应用请求,并在清理虚拟机后关闭 HTTP 长连接。
+
+验收应分别验证原生 ⌘Q/菜单退出、窗口关闭、连续重新打开、真实后台停止及重新启动。仅调用测试框架的关闭方法不能证明一次原生退出完成。

+ 44 - 0
docs/local-vm.md

@@ -0,0 +1,44 @@
+# 本地虚拟机
+
+本实现参照 OpenMausBot 的 `LocalComputerSection.tsx`、`ComputerPanel.tsx`、`LocalVmWorkspace.tsx` 及手机 `ComputerView.swift` / `ComputerScreen.kt`。
+
+## 使用流程
+
+1. 进入应用设置 → **本地虚拟机**。macOS App 的应用菜单也提供“本地虚拟机设置…”。
+2. 检查 Docker/Podman 已安装并启动,点击 **准备本地虚拟机**。本机执行环境自动连接,不需要手工填写镜像 ID、下载 Runner 配置再导入。准备会复用兼容镜像或构建固定版本,并验证实际桌面后自动使用。
+3. 选择 **共享虚拟机** 或 **每个 Agent 独立**,最多同时运行 1–4 台,默认 2 台。同一账号、同一基础 Profile 的持久 Agent 共用桌面、工作文件和浏览器资料,新建成员会继承当前共享策略。临时任务助手继续使用任务隔离环境,完成后单独清理,不会删除持久共享工作区。
+4. 打开 Agent 聊天,点击右上角 **电脑**。右侧电脑面板中选择 **本地虚拟机**,从这里创建或启动该 Agent 的桌面。
+5. 右侧显示真实画面及运行状态,可停止或重建虚拟机;点击预览或 **打开桌面** 后接管交互。
+6. **打开双桌面** 在主工作区显示两台虚拟机,保留聊天侧栏。进入时仅查看,不会创建/启动机器;切换操作对象时先交还前一台的控制权。
+
+虚拟机实例移除或重建均保留工作目录和浏览器资料。正常完成任务和主动交还后桌面进入空闲状态;空闲 5 分钟后自动停止。取消、授权撤销、异常退出仍停止旧执行实例,以隔离迟到操作。基础 Profile 工作目录变化会在空闲时重建运行实例,数据保留。
+
+切换共享方式不会修改普通 Profile 成员,也不会因其聊天任务而被阻止。已经交还控制权的空闲桌面可保持运行;虚拟机任务仍在执行、人工控制尚未交还或停止状态待核对时,继续保护原执行并拒绝切换。成员任务造成阻挡时,提示会显示具体成员名称。
+
+`terminal.cwd` 默认的 `.`(以及 `auto`、`cwd`、未设置)指向虚拟机的持久工作区 `/home/cua/workspace`;相对路径以该工作区为起点,显式绝对路径继续沿用配置。创建、启动和人工接管只读取工作目录,不要求模型可用;停止和移除已有实例也不依赖 Profile 配置。模型仅在运行 Agent 时解析,Profile、目录和模型错误分别提示。
+
+## 手机端
+
+iOS/Android 没有全局虚拟桌面清单、镜像设置或执行环境选择。入口在 **当前 Agent 聊天右上角的电脑图标**。单个 Agent 直接打开画面,群聊有多台电脑时选择当前聊天成员的电脑,同一共享环境只显示一次。
+
+黑底全屏、按原比例显示画面,顶部是 Agent 名称、共享标识和状态,底部是接管/交还操作。键盘、文字、滚动及交还说明单独展开。未启用电脑的 Agent 显示桌面端配置提示,不由手机自动创建环境。
+
+## 范围与接口
+
+- 已删除此前的“电脑与环境”聊天列表入口、独立镜像目录、应用/回退/导入/导出界面及相应管理 HTTP API。
+- 旧工作区、旧共享数据、已下载归档和 Docker 镜像不会因删除界面而被删除。无人使用的保留实例可在本地虚拟机设置中停止或移除,工作文件继续保留。
+- 托管的本机 Runner 凭据只在服务端加密保存,重启 Web 后自动连接;高级外部 Runner 仍可沿用现有连接配置。
+- 本地虚拟机运行在当前连接的服务所在电脑,不跨机器合并容量。
+- `GET /api/app/admin/local-vm` 提供准备状态、隔离策略和实例清单;`POST /prepare` 准备托管镜像,`PUT /policy` 更新策略。
+- `/api/app/agents/:id/local-vm` 提供该 Agent 的配置和状态,`POST /:action` 提供 create/start/stop/recreate/remove。
+- 屏幕与输入沿用受控的 `/api/app/agents/:id/computer` 通道。输入绑定账号、Agent、当前控制令牌、租约版本和截图;旧租约不能继续操作。
+- 应用设置只向管理员开放。共享不会跨账号或 Profile;实例操作会拒绝正在由 Agent 或其他人控制的虚拟机。
+
+## 验收
+
+测试覆盖准备与权限、持久化、实例生命周期、空闲配额、控制权失效、共享范围、聊天右侧面板、双桌面移交及手机聊天入口。另有真实 Docker + Hermes Worker 和从设置自动准备到聊天内操作真实桌面的测试,界面截图来自隔离验收环境。
+
+
+本轮验收结果:787 项单元/集成测试通过;7 项环境限定测试跳过。7 项浏览器流程、iOS 3 项界面流程、Android 2 项界面流程通过。真实 Hermes Worker + Docker 流程 2 项通过;自动准备本机 Runner、真实桌面键盘输入及快捷键、重启保留文件、双桌面控制切换的完整界面流程通过。
+
+截图:[本地虚拟机设置](screenshots/local-vm/settings.png)、[Agent 聊天右侧电脑面板](screenshots/local-vm/agent-panel.png)、[交互桌面](screenshots/local-vm/interactive-desktop.png)、[双桌面工作区](screenshots/local-vm/two-desktops.png)、[手机聊天入口](screenshots/local-vm/phone-chat-entry.png)。

+ 59 - 0
docs/macos-app.md

@@ -0,0 +1,59 @@
+# macOS App 与本地服务
+
+App、Web 服务、Vue 界面和 Runner 同仓维护。正式 App 在每个构建首次启动时,将较旧的本机 Web 同步为配套构建;已有更新的 Web 保留。Web 由独立 LaunchAgent 运行,App 连接它。源码开发模式仍使用包内子进程。默认数据目录为 `~/.hermes-yaoyao`,默认 Web 端口为 15300。Hermes 是独立基础运行时,由本地服务检测并按既有托管逻辑启动;隔离电脑另需本机 Docker 或 Podman。
+
+## 生命周期与权限
+
+- App 单实例;服务和 Runner 各自对实际数据目录持有 SQLite 独占锁。
+- 使用私有随机凭据核对服务实例、PID、数据目录、协议及版本。App 自己启动的服务必须匹配内置版本。连接旧版本外部服务时使用其界面和能力声明。
+- 老 CLI 服务没有描述文件时,只有确认它同时持有目标 SQLite 文件和监听端口才连接。
+- 关闭窗口保留任务;菜单栏和 Dock 可恢复。正式 App 退出时停止自己托管的 Runner,独立 Web 服务继续运行。源码开发 App 仍清理自己的服务子进程。停止无法确认时显示实际清理错误。
+- 服务崩溃有界重启,睡眠恢复后重新核对连接;错误页面提供重试和日志。
+- Runner 配置由原生文件选择器导入。新配置用 AES-GCM 加密,密钥按数据目录保存在系统钥匙串中;加密助手仅允许所属、同签名团队的 App 调用,明文通过私有进程管道传递。旧 safeStorage 格式仍可在系统授权后读取。Web 页面没有配置解密或桌面启动权限,外部页面不能调用原生桥。
+
+## 登录启动
+
+“夭夭 → 登录 macOS 时启动”使用系统登录项,默认不开启。用户可另选“登录启动时仅驻留菜单栏”;这个偏好只影响登录启动,手动打开 App 仍显示窗口。正常安装不会改写系统登录项。
+
+源码开发 App 不提供登录项开关;安装包中的开关由系统管理。实际开机/重新登录验收尚未执行,当前验证的是偏好持久化和手动重新打开行为。
+
+## 更新与回退
+
+正式 App 的同步在安装/更新后的**首次启动**执行;复制 App 文件本身不执行服务操作。详见 [App 与 Web 版本同步](app-web-sync.md)。
+
+- 同步仅针对当前用户、相同数据目录和端口的本机 LaunchAgent;保留已有服务的地址、TLS、上游及通知配置。
+- 使用版本、提交关系和完整服务包校验值识别构建。同版本不同提交可以向上同步;不同分支且无法确认先后时保留现有服务并提示原因。
+- App 携带独立 Node 运行时及预编译 Web 服务,首次同步不依赖联网、npm 或源码构建。Node 的许可证随服务包交付。
+- 同步成功按 App 服务包校验值记账;同一构建再次打开不会反复覆盖 Web。Web 可以继续通过原有更新入口升级。
+- 新版服务空闲时短暂暂停请求,停止后备份数据、切换版本,验证实际构建、健康及关键接口后开放请求。失败恢复程序与数据;进程中断由下一次同步或更新恢复。
+- 原有旧版服务缺少协作暂停接口时,需要确认无未完成任务及本机服务进程的客户端连接。忙碌时 App 先连接已验证的原 Web,在菜单提示同步待完成并提供重试,不强行中断聊天。不能验证身份、TLS 或数据归属的服务不接管,启动页显示错误及恢复操作。
+- 回退单独通过 Web 的回滚入口处理。只在数据库结构兼容时切回程序并保留现有数据;结构已变化或缺少兼容性记录时拒绝直接降级,不能用旧备份覆盖升级后的新消息。
+
+正式后台服务的程序位于 `~/.local/share/hermes-yaoyao/releases`,`current` 指向启用的构建;数据仍在 `~/.hermes-yaoyao`。App 签名资源不被 Web 更新器改写。开发模式的包内服务仍随开发 App 管理,不提供服务内更新。
+
+## 构建与验收
+
+```sh
+npm run desktop:build
+npm run desktop:test
+npm run desktop:pack
+codesign --verify --deep --strict desktop-release/mac-arm64/夭夭.app
+hdiutil verify desktop-release/Yaoyao-0.3.32-arm64.dmg
+```
+
+开发 App 的 6 项测试通过:真实内置服务启动、账号与 Runner 连接、关闭窗口保持运行、服务崩溃恢复、外部客户端不误停服务、退出清理、加密配置再次启动、偏好保存及手动重新打开、版本不符拒绝、钥匙串失败保护。另有更新器 14 项定向测试通过,包含 App 禁用源码更新及回退。
+
+最终分支包位于 `desktop-release/agent-platform`;旧的顶层包保留为历史快照。新包通过严格签名、DMG 完整性及实际 App 生命周期验证。使用 Apple Development 开发签名,未公证、未安装或发布,详见 `agent-platform-implementation.md`。
+
+### 原生加密核对
+
+Electron 初始化时设置其钥匙串项目名称([v43.4.0 源码](https://github.com/electron/electron/blob/v43.4.0/shell/browser/electron_browser_main_parts.cc#L524))。首次签名包验收在旧项目的系统授权上等待,未修改其权限。新配置改用 Runner 专属、按数据目录区分的原生项目;旧配置保持兼容读取,不强制迁移。
+
+助手使用 CryptoKit AES-GCM、Security.framework 和私有 stdin/stdout,不把密钥放进环境变量、参数或日志。分发版本验证调用 App 路径、签名和团队,拒绝外部 shell 调用;仅用于源码开发的测试助手不会进入安装包。钥匙串调用有 30 秒上限,失败保留原配置。
+
+原生钥匙串加密往返、密文篡改拒绝、跨数据目录拒绝、外部直接调用拒绝,以及完整 Runner 导入/退出/恢复等 8 项桌面测试通过。
+
+
+最终实际签名 App 的完整 Runner 导入、加密保存、服务崩溃恢复、窗口关闭保持连接、退出清理和再次打开自动连接均已通过;另有独立的核心服务生命周期测试通过。新加密助手在实际签名 App 中验证所属路径和签名团队。安装包经挂载检查,开发专用助手没有进入分发包。
+
+已有正式服务会被识别并复用,App 不会自动把它换成分支代码。需要独立预览时,先退出其他夭夭 App,再指定独立 `HERMES_YAOYAO_HOME` 和 `HERMES_YAOYAO_DESKTOP_PORT` 启动;交付目录 `README.txt` 提供具体命令。

+ 59 - 0
docs/runner.md

@@ -0,0 +1,59 @@
+# Hermes 执行节点
+
+Runner 在运行 Hermes 的电脑上主动连接夭夭服务。用于 Web 位于 Docker、另一台电脑或远程服务器时,将 Bot 会话和本轮组队工具交给 Hermes 所在电脑执行。Runner 源码、构建入口和管理页面均位于 hermes-yaoyao。
+
+普通执行方式继续使用原有 Hermes。配置电脑 Worker 后,Agent 可选择隔离电脑执行,模型工具不会退回宿主。具体链路与验证边界见 `docs/computer-worker.md`;公网代理已接入;屏幕接管与共享环境仍待完成。
+
+## 启用
+
+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 可直接通过菜单栏“执行节点 → 导入节点配置…”导入下载的配置,无需另外安装 Node.js。App 使用系统钥匙串支持的加密存储保存配置,通过私有进程消息传入内置 Runner;凭据不进入命令行或子进程环境变量。随后每次打开 App 都会自动启动已配置的 Runner。关闭窗口保持连接,退出 App 时清理自己管理的 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 验收。

BIN
docs/screenshots/local-vm/agent-panel.png


BIN
docs/screenshots/local-vm/interactive-desktop.png


BIN
docs/screenshots/local-vm/phone-chat-entry.png


BIN
docs/screenshots/local-vm/phone-desktop.png


BIN
docs/screenshots/local-vm/settings.png


BIN
docs/screenshots/local-vm/two-desktops.png


+ 36 - 0
docs/shared-computers.md

@@ -0,0 +1,36 @@
+# 可信成员共享电脑
+
+默认每个 Agent 使用自己的电脑、工作目录及浏览器资料。用户可在 Web/macOS 的“电脑 → 电脑共享设置”中选择 2–8 名持久成员,明确确认共享工作文件与浏览器登录资料后创建共享电脑。成员必须属于当前账号,使用同一来源、同一 Profile 和同一个支持 `shared-computer-v1` 的 Runner。
+
+## 执行与数据边界
+
+- 共享电脑是新工作区。原独立电脑的数据保留,不隐式搬运或合并。
+- 共享成员共用电脑环境及浏览器资料,所以只适用于彼此可信的成员;他们不拥有彼此的模型会话历史。Worker 会话同时核对 Agent、账号、环境和 Profile。
+- 同一电脑的租约互斥。一个成员执行时,其他成员等待资源,未提交的任务不消耗执行重试次数;取消只停止持有该租约的工作。
+- 电脑面板的成员选择器可查看不同的独立电脑;选择共享成员时看到同一环境。同一画面只允许一个人工控制会话,不能从另一个成员入口绕过控制权。
+- 创建和解除共享都要求成员没有排队或在途任务、没有有效人工接管。模型组队工具不能传入共享环境 ID,也不能创建或修改共享授权。临时助手始终使用任务所属的独立电脑。
+- 共享电脑固定原 Runner。更换来源 Runner 时明确报错,不在另一台机器静默创建空环境。旧 Runner 能力不足时拒绝共享执行。
+- 解除共享后,各成员回到原独立电脑;共享数据保留在原 Runner,仍可通过维护工具按环境 ID 备份。解除共享不是删除共享文件。
+
+同一共享电脑是一个桌面,输入串行;多名 Agent 的独立电脑可并行运行。当前没有在同一容器中提供多个独立 X 桌面,也不将共享浏览器资料宣称为成员间隔离。
+
+## 客户端
+
+Web 与 macOS 提供创建/解除入口。iOS、Android 显示共享名称、资料范围并支持查看和接管;共享成员设置引导到 Web/macOS。所有操作仍由服务器检查权限,移动端不是绕过共享授权的通道。
+
+## 验证
+
+- 服务端覆盖显式信任、账号/成员/来源边界、临时助手拒绝、模型参数拒绝、重复请求、在途任务与人工接管期间拒绝修改、解除后旧授权拒绝。
+- 真实 Docker、新镜像、打包 Runner 与 Hermes Worker:两名 Agent 先后访问同一工作文件,等待占用的电脑;第二人的模型历史不包含第一人的用户消息;原独立文件保留。共享电脑不能经第二个成员入口重复人工接管,控制中不能解除共享。
+- Web 六项流程通过,包含创建和解除共享,窄屏截图已核对。
+- iOS 28 项 Workspace 测试和 Android 模型/编译通过;两端生产电脑面板的 UI 测试均经过一次真实 HTTP 往返,验证只读、接管、文字输入、交还说明并回到 Agent 状态。界面截图使用固定测试图片;实际电脑输入效果由上述 Docker 验收证明,两类证据分别记录。
+
+原生 UI 的隔离测试服务可用以下命令启动,测试结束后关闭:
+
+```sh
+python3 tests/fixtures/native-computer.py --port 18842 --image public/icons/icon-512.png # iOS
+python3 tests/fixtures/native-computer.py --port 18841 --image public/icons/icon-512.png # Android
+```
+
+
+2026-09-09 本地虚拟机的新流程以 `local-vm.md` 为准:准备与隔离策略在应用设置,电脑预览和实例操作在 Agent 聊天右侧;手机端仅从当前聊天查看与操作电脑。

+ 31 - 0
docs/task-helpers.md

@@ -0,0 +1,31 @@
+# 任务级临时助手
+
+管理员可使用 `workspace_create_helper` 为已有团队目标创建一个临时隔离助手,并同时分派具体子任务。与持久 Agent 不同,临时助手没有独立单聊,不加入团队的永久成员列表,只参与创建它的目标和该目标当前的运行阶段。
+
+## 生命周期与权限
+
+- 创建时记录 `temporaryGoalId`、`helperActivation`、创建者、来源运行和原 Runner ID。调用身份由当前受权轮次提供,模型不能指定这些字段。
+- 工具必须提供目标、名称和具体工作,可补充职责、验收要求和依赖。创建助手与子任务在同一个持久命令中完成,同一 requestId 重试不会重复创建。
+- 每个目标最多 8 个有效临时助手、累计最多 32 个;执行仍受 Agent 串行约束和节点电脑配额限制。
+- 助手固定使用隔离电脑,不获得组队权限,不能变成持久成员、加入其他群聊、切换执行方式或执行其他任务。
+- 用户恢复目标时会产生新阶段。旧阶段的临时助手不恢复;需要继续工作时创建新助手或由管理员调整负责人。
+- 只有声明 `helper-retirement-v1` 的 Runner 才会向管理员提供创建助手工具,避免在不能清理的旧执行节点上创建临时资源。
+
+## 退役与清理
+
+目标完成、受阻、取消、删除,或进入新运行阶段后,旧助手退役。先撤销执行资格,再停止所属运行;不能确认停止时保留待清理状态,不把工作区交给其他任务。
+
+清理状态保存在助手记录中,服务重启后继续检查。离线节点或失败的清理会重试,界面显示“已退役,等待清理”。清理只发往原 Runner;更换节点不会把清理请求发送到另一台电脑。
+
+Runner 在删除资源前保存退役记录和原配置,核对环境归属,停止相关模型进程、释放电脑、移除工作目录和临时模型会话。退役标记保留,迟到请求不能重新创建该电脑。已导出的文件保存在控制面,原作者和任务来源不变,不随工作区删除。
+
+## 跨端表现
+
+Web、iOS 和 Android 的任务进度显示“临时助手”及退役状态。持久团队的成员选择器排除这些助手;后端同时执行任务边界验证,旧客户端或手工请求也不能绕过。
+
+## 验收
+
+- 服务测试覆盖创建去重、跨任务拒绝、禁止提权和转成持久成员、取消后退役、异步清理,以及清理中断后的重试。
+- 真实 Docker / Hermes Worker 验收:管理员创建临时助手,助手完成隔离工作并导出文件;目标结束后工作目录被删除,已回传产物保留。
+- 模型服务在该验收中使用确定的测试响应,没有使用付费模型;它证明执行协议和生命周期,不代表任意任务的自主决策质量。
+- 移动端校验任务身份解码、状态文案和成员选择过滤;完整电脑面板与人工接管仍属于后续交付范围。

+ 49 - 0
electron-builder.yml

@@ -0,0 +1,49 @@
+appId: cn.samien.yaoyao.desktop
+productName: 夭夭
+directories:
+  output: desktop-release
+  app: .desktop-build/shell
+files:
+  - '**/*'
+extraResources:
+  - from: .desktop-build
+    to: runtime
+    filter:
+      - server.mjs
+      - server.mjs.map
+      - runner.mjs
+      - runner.mjs.map
+      - runner-image.mjs
+      - runner-maintenance.mjs
+      - computer-image/**/*
+      - third-party/**/*
+      - runner-config.mjs
+      - runner-config.mjs.map
+      - hermes_worker.py
+      - guest_proxy.py
+      - release.json
+      - build-info.json
+      - keychain-helper
+      - ui/**/*
+      - web-service/**/*
+asar: true
+npmRebuild: false
+mac:
+  # Keep the independently signed Node runtime byte-identical to its sealed manifest.
+  signIgnore:
+    - /web-service/node$
+  category: public.app-category.productivity
+  icon: public/icons/icon-512.png
+  target:
+    - target: dir
+      arch: [arm64]
+    - target: dmg
+      arch: [arm64]
+  hardenedRuntime: true
+  gatekeeperAssess: false
+  extendInfo:
+    NSMicrophoneUsageDescription: 夭夭使用麦克风接收语音消息。
+dmg:
+  title: 夭夭 ${version}
+artifactName: Yaoyao-${version}-${arch}.${ext}
+publish: null

File diff suppressed because it is too large
+ 936 - 14
package-lock.json


+ 14 - 2
package.json

@@ -5,6 +5,7 @@
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",
   "type": "module",
+  "main": "desktop/main.mjs",
   "engines": {
     "node": ">=24.0.0"
   },
@@ -13,7 +14,7 @@
   },
   "scripts": {
     "dev": "cross-env NODE_ENV=development tsx watch src/server/index.ts",
-    "build": "npm run brand:verify && npm run release:verify && vue-tsc -b tsconfig.client.build.json && vite build && tsc -p tsconfig.server.json",
+    "build": "npm run brand:verify && npm run release:verify && vue-tsc -b tsconfig.client.build.json && vite build && tsc -p tsconfig.server.json && node scripts/copy-worker.mjs && node scripts/write-build-info.mjs",
     "start": "cross-env NODE_ENV=production node dist-server/server/index.js",
     "typecheck": "vue-tsc -b && tsc --noEmit -p tsconfig.server.json",
     "test": "vitest run",
@@ -23,7 +24,15 @@
     "brand:verify": "node scripts/verify-brand.mjs",
     "release:verify": "node scripts/verify-release.mjs",
     "verify:lifecycle": "node scripts/verify-lifecycle.mjs",
-    "test:e2e:workspace": "npm run build && playwright test -c playwright.workspace.config.ts"
+    "test:e2e:workspace": "npm run build && playwright test -c playwright.workspace.config.ts",
+    "test:e2e:team-tools": "npm run build && playwright test -c playwright.team-tools.config.ts",
+    "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",
+    "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",
@@ -73,6 +82,9 @@
     "@vitest/coverage-v8": "^3.2.4",
     "@vue/test-utils": "^2.4.6",
     "cross-env": "^10.1.0",
+    "electron": "43.4.0",
+    "electron-builder": "26.15.3",
+    "esbuild": "0.28.2",
     "jsdom": "^27.0.1",
     "supertest": "^7.1.4",
     "tsx": "^4.21.0",

+ 10 - 0
playwright.team-tools.config.ts

@@ -0,0 +1,10 @@
+import { defineConfig, devices } from '@playwright/test'
+export default defineConfig({
+  testDir: './tests/e2e', testMatch: 'team-tools.spec.ts', workers: 1, timeout: 45_000, reporter: 'list',
+  use: { baseURL: 'http://127.0.0.1:18808', trace: 'retain-on-failure', screenshot: 'only-on-failure' },
+  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+  webServer: {
+    command: 'WORKSPACE_FIXTURE_TEAM_TOOLS=1 WORKSPACE_FIXTURE_PORT=18808 WORKSPACE_FIXTURE_UPSTREAM_PORT=19128 node --import tsx tests/fixtures/workspace-server.ts',
+    url: 'http://127.0.0.1:18808/healthz', reuseExistingServer: false, timeout: 20_000,
+  },
+})

+ 44 - 0
scripts/build-desktop.mjs

@@ -0,0 +1,44 @@
+import { build } from 'esbuild'
+import { execFileSync } from 'node:child_process'
+import { buildRunner } from './build-runner.mjs'
+import { cp, mkdir, rm, readdir, readFile, writeFile, realpath, chmod } from 'node:fs/promises'
+import { resolve } from 'node:path'
+import { sealRuntime } from '../bin/lib/runtime-release.mjs'
+
+const root = resolve(import.meta.dirname, '..'), out = resolve(root, '.desktop-build')
+await rm(out, { recursive: true, force: true })
+await mkdir(out, { recursive: true })
+await build({ entryPoints: [resolve(root, 'src/server/index.ts')], outfile: resolve(out, 'server.mjs'),
+  bundle: true, platform: 'node', format: 'esm', target: 'node24', external: ['vite'],
+  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)
+const swiftTarget=process.arch==='arm64'?'arm64-apple-macosx12.0':'x86_64-apple-macosx12.0'
+execFileSync('xcrun',['swiftc','-O','-target',swiftTarget,resolve(root,'desktop/keychain-helper.swift'),'-o',resolve(out,'keychain-helper')],{stdio:'inherit'})
+execFileSync('xcrun',['swiftc','-O','-target',swiftTarget,'-D','DEVELOPMENT_HELPER',resolve(root,'desktop/keychain-helper.swift'),'-o',resolve(out,'keychain-helper-dev')],{stdio:'inherit'})
+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'))
+const shell = resolve(out, 'shell')
+await mkdir(shell)
+for (const file of await readdir(resolve(root, 'desktop'))) {
+  if (file.endsWith('.test.mjs') || !/\.(js|mjs|cjs|html|css|png)$/.test(file)) continue
+  await cp(resolve(root, 'desktop', file), resolve(shell, file))
+}
+for (const file of ['boot.html', 'boot.js', 'boot.css', 'preload.cjs']) await readFile(resolve(shell, file))
+await cp(resolve(root,'build-info.json'),resolve(out,'build-info.json'))
+const manifest = JSON.parse(await readFile(resolve(root, 'release.json'), 'utf8'))
+const service = resolve(out, 'web-service')
+await mkdir(service)
+for (const file of ['server.mjs', 'ui', 'release.json', 'build-info.json', 'hermes_worker.py', 'guest_proxy.py', 'computer-image', 'third-party']) await cp(resolve(out, file), resolve(service, file), { recursive: true })
+await cp(resolve(root, 'bin'), resolve(service, 'bin'), { recursive: true })
+await cp(await realpath(process.execPath), resolve(service, 'node'))
+await cp(resolve(await realpath(process.execPath), '../../LICENSE'), resolve(service, 'NODE-LICENSE.txt'))
+await chmod(resolve(service, 'node'), 0o755)
+await writeFile(resolve(service, 'package.json'), JSON.stringify({ name: 'hermes-yaoyao', version: manifest.webVersion, type: 'module' }))
+sealRuntime(service)
+await writeFile(resolve(shell, 'package.json'), JSON.stringify({ name: 'hermes-yaoyao-desktop', version: manifest.webVersion,
+  productName: '夭夭', description: '夭夭:本地服务、多 Agent 团队与电脑工作空间',
+  author: 'YaoYao contributors', license: 'BSL-1.1', main: 'main.mjs', type: 'module' }, null, 2))
+console.log('桌面服务和界面已打包到 .desktop-build')

+ 22 - 0
scripts/build-runner.mjs

@@ -0,0 +1,22 @@
+import { build } from 'esbuild'
+import { mkdir,copyFile,cp } from 'node:fs/promises'
+import { resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+export async function buildRunner(directory = resolve(import.meta.dirname, '../.runner-build')) {
+  await mkdir(directory, { recursive: true })
+  await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))
+  await copyFile(resolve(import.meta.dirname,'../src/runner/network/guest_proxy.py'),resolve(directory,'guest_proxy.py'))
+  await cp(resolve(import.meta.dirname,'../deploy/computer'),resolve(directory,'computer-image'),{recursive:true})
+  await cp(resolve(import.meta.dirname,'../third-party'),resolve(directory,'third-party'),{recursive:true})
+  await build({
+    entryPoints: { 'runner-maintenance':resolve(import.meta.dirname, '../src/runner/computers/maintenanceCli.ts'), 'runner-image':resolve(import.meta.dirname, '../src/runner/computers/imageCli.ts'), 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',
+    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 或更高版本)')
+}

+ 11 - 0
scripts/copy-worker.mjs

@@ -0,0 +1,11 @@
+import {mkdir,copyFile,cp} 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'))
+
+await copyFile(resolve(import.meta.dirname,'../src/runner/network/guest_proxy.py'),resolve(directory,'guest_proxy.py'))
+
+await cp(resolve(import.meta.dirname,'../deploy/computer'),resolve(directory,'../computers/computer-image'),{recursive:true})
+
+await cp(resolve(import.meta.dirname,'../third-party'),resolve(directory,'../computers/third-party'),{recursive:true})

+ 6 - 0
scripts/verify-lifecycle.mjs

@@ -1,8 +1,12 @@
 import { spawn, spawnSync } from 'node:child_process'
 import { setTimeout as delay } from 'node:timers/promises'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
 
 const port = Number(process.env.HERMES_YAOYAO_VERIFY_PORT || 18800)
 const entry = new URL('../dist-server/server/index.js', import.meta.url)
+const verificationHome = mkdtempSync(join(tmpdir(), 'yaoyao-lifecycle-'))
 
 function listenerPids(targetPort) {
   const result = spawnSync('lsof', ['-nP', `-iTCP:${targetPort}`, '-sTCP:LISTEN', '-t'], {
@@ -34,6 +38,7 @@ const child = spawn(process.execPath, [entry.pathname], {
     ...process.env,
     NODE_ENV: 'production',
     HERMES_YAOYAO_HOST: '127.0.0.1',
+    HERMES_YAOYAO_HOME: verificationHome,
     HERMES_YAOYAO_PORT: String(port),
     HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119',
     HERMES_YAOYAO_SUPERVISE_DASHBOARD: '0',
@@ -54,6 +59,7 @@ try {
     new Promise(resolve => child.once('exit', resolve)),
     delay(5_000).then(() => child.kill('SIGKILL')),
   ])
+  rmSync(verificationHome, { recursive: true, force: true })
 }
 
 const gatewayAfter = listenerPids(9119)

+ 5 - 0
scripts/write-build-info.mjs

@@ -0,0 +1,5 @@
+import { writeFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { sourceBuild } from '../bin/lib/runtime-release.mjs'
+const root = resolve(import.meta.dirname, '..')
+writeFileSync(resolve(root, 'build-info.json'), JSON.stringify({ ...sourceBuild(root), builtAt: new Date().toISOString() }, null, 2) + '\n')

+ 2 - 1
src/client/api/systemUpdate.ts

@@ -33,7 +33,8 @@ export interface UpdateJob {
 
 export interface SystemUpdateStatus {
   current: ReleaseManifest
-  installationMode: 'source' | 'release'
+  build?: { commit: string; buildNumber: number; dirty: boolean; artifactDigest?: string }
+  installationMode: 'source' | 'release' | 'desktop'
   latest?: ReleaseManifest
   updateAvailable: boolean
   supported: boolean

+ 65 - 0
src/client/components/app/LocalVmSettingsPanel.vue

@@ -0,0 +1,65 @@
+<script setup lang="ts">
+import {ref,onMounted,onBeforeUnmount} from 'vue'
+import {apiRequest} from '@/api/client'
+import {createUuid} from '@/utils/id'
+import AppIcon from '@/components/common/AppIcon.vue'
+import type {LocalVmStatus,LocalVmMode,LocalVmAction,LocalVmInstance} from '@shared/localVm'
+import type {WorkspaceAgent} from '@shared/workspace'
+const state=ref<LocalVmStatus>(),error=ref(''),loading=ref(false),acting=ref(false)
+const instances=ref<Array<{agent:WorkspaceAgent;vm:LocalVmInstance}>>([])
+let closed=false,timer:ReturnType<typeof setTimeout>|undefined
+async function refresh(){
+ if(loading.value||acting.value||closed)return
+ loading.value=true
+ try{
+  const next=await apiRequest<LocalVmStatus>('/api/app/admin/local-vm');if(closed)return;state.value=next
+  const list=await apiRequest<{computers:Array<{agent:WorkspaceAgent}>}>('/api/app/computers')
+  const seen=new Set<string>()
+  const agents=list.computers.map(x=>x.agent).filter(a=>a.execution==='computer'&&!a.remoteAgentId&&a.nodeId==='local'&&!seen.has(a.computerEnvironmentId??a.id)&&!!seen.add(a.computerEnvironmentId??a.id))
+  const rows=await Promise.all(agents.map(async agent=>({agent,vm:await apiRequest<LocalVmInstance>(`/api/app/agents/${agent.id}/local-vm`)})))
+  if(!closed)instances.value=rows
+ }catch(e){if(!closed)error.value=e instanceof Error?e.message:'无法读取本地虚拟机状态'}finally{loading.value=false}
+}
+async function action(work:()=>Promise<unknown>){acting.value=true;error.value='';try{await work()}catch(e){error.value=e instanceof Error?e.message:'操作未完成'}finally{acting.value=false;await refresh()}}
+function prepare(){void action(()=>apiRequest('/api/app/admin/local-vm/prepare',{method:'POST',body:{requestId:createUuid()}}))}
+function policy(mode:LocalVmMode,maxInstances=state.value?.maxInstances??2){
+ if(mode==='shared'&&state.value?.mode!=='shared'&&!confirm('同一账号、同一基础 Agent 的机器人将共用桌面、工作文件和浏览器登录。确认使用共享虚拟机?'))return
+ void action(()=>apiRequest('/api/app/admin/local-vm/policy',{method:'PUT',body:{requestId:createUuid(),mode,maxInstances}}))
+}
+function manage(agent:WorkspaceAgent,operation:LocalVmAction){
+ if(operation==='remove'&&!confirm(`移除“${agent.name}”的虚拟机实例?工作文件和浏览器资料将保留。`))return
+ void action(()=>apiRequest(`/api/app/agents/${agent.id}/local-vm/${operation}`,{method:'POST',body:{}}))
+}
+async function cycle(){await refresh();if(!closed)timer=setTimeout(cycle,state.value?.busy?2000:10000)}
+onMounted(cycle);onBeforeUnmount(()=>{closed=true;clearTimeout(timer)})
+</script>
+<template>
+ <section class="local-vm-settings" aria-label="本地虚拟机设置">
+  <article><h3>本地虚拟机</h3><p>在这台电脑的隔离 Linux 桌面中运行 Agent,工作文件与浏览器资料持续保存。</p>
+   <div class="row"><span class="status"><AppIcon :name="state?.image?'check':'monitor'"/>{{loading&&!state?'正在检查…':state?.image?'虚拟机环境已就绪':'尚未就绪'}}</span><button :disabled="loading||acting" @click="error='';refresh()"><AppIcon name="refresh"/>重新检查</button></div>
+   <p v-if="state?.problem">{{state.problem}}</p><p v-if="error" role="alert">{{error}}</p>
+   <p v-if="state?.job" role="status">{{state.job.message}}</p>
+  </article>
+  <article><h3>隔离方式</h3><p>按 Agent 创建独立桌面,或让可信的 Agent 共用一个桌面。</p>
+   <div class="segmented" role="group" aria-label="虚拟机隔离方式"><button :aria-pressed="state?.mode==='shared'" :disabled="!state?.configured||acting||state.busy" @click="policy('shared')">共享虚拟机</button><button :aria-pressed="state?.mode==='per-bot'" :disabled="!state?.configured||acting||state.busy" @click="policy('per-bot')">每个 Agent 独立</button></div>
+   <label class="row">最多同时运行的虚拟机<select aria-label="虚拟机数量上限" :value="state?.maxInstances??2" :disabled="!state?.configured||acting||state.busy" @change="policy(state!.mode,Number(($event.target as HTMLSelectElement).value))"><option v-for="n in [1,2,3,4]" :key="n" :value="n">{{n}}</option></select></label>
+  </article>
+  <article><h3>准备运行环境</h3><ol>
+   <li><span class="step">{{state?.runtime?'✓':'1'}}</span><div><strong>安装 Docker 或 Podman</strong><p>{{state?.runtime?`已检测到 ${state.runtime}`:'请先安装 Docker Desktop 或 Podman。'}}</p></div></li>
+   <li><span class="step">{{state?.daemonUp?'✓':'2'}}</span><div><strong>启动容器运行环境</strong><p>{{state?.daemonUp?'运行环境已启动':'打开 Docker / Podman,等待它完成启动后重新检查。'}}</p></div></li>
+   <li><span class="step">{{state?.image?'✓':'3'}}</span><div><strong>准备本地虚拟机</strong><p>下载并检查托管桌面环境。后续创建虚拟机时会自动使用。</p><button class="primary" :disabled="acting||state?.busy||!state?.daemonUp" @click="prepare">{{state?.busy?'正在准备…':state?.image?'重新检查虚拟机镜像':'准备本地虚拟机'}}</button></div></li>
+   <li><span class="step">4</span><div><strong>从 Agent 聊天中创建桌面</strong><p>打开聊天右上角的“电脑”,选择“本地虚拟机”,然后创建该 Agent 的虚拟机。</p></div></li>
+  </ol></article>
+  <article><h3>{{state?.mode==='shared'?'共享虚拟机':'Agent 虚拟机'}}</h3><p v-if="!instances.length">还没有 Agent 使用本地虚拟机。</p>
+   <div v-for="row in instances" :key="row.agent.id" class="instance"><div><strong>{{row.agent.computerEnvironmentName??row.agent.name}}</strong><p>{{row.vm.inUse?'正在使用':({running:'运行中',stopped:'已停止',missing:'尚未创建'})[row.vm.container]}}</p></div><div class="row">
+    <button v-if="row.vm.container==='running'" :disabled="acting||row.vm.inUse" @click="manage(row.agent,'stop')">停止</button>
+    <button v-else :disabled="acting||!state?.image" @click="manage(row.agent,row.vm.container==='missing'?'create':'start')">{{row.vm.container==='missing'?'创建':'启动'}}</button>
+    <button v-if="row.vm.container!=='missing'" :disabled="acting||row.vm.inUse" @click="manage(row.agent,'remove')">移除实例</button>
+   </div></div>
+   <div v-for="item in state?.instances?.filter(i=>i.orphaned)" :key="item.id" class="instance"><div><strong>{{item.name}}</strong><p>暂无 Agent 使用 · {{item.status==='free'?'已停止':'运行中或待核对'}}</p></div><div class="row"><button :disabled="acting" @click="action(()=>apiRequest(`/api/app/admin/local-vm/instances/${item.id}/stop`,{method:'POST',body:{requestId:createUuid()}}))">停止</button><button :disabled="acting" @click="action(()=>apiRequest(`/api/app/admin/local-vm/instances/${item.id}/remove`,{method:'POST',body:{requestId:createUuid()}}))">移除实例</button></div></div>
+  </article>
+ </section>
+</template>
+<style scoped>
+.local-vm-settings{display:grid;gap:16px;max-width:820px}article{padding:20px;border:1px solid var(--line);border-radius:14px;display:grid;gap:12px;background:var(--surface)}h3,p{margin:0}h3{font-size:15px}p{font-size:13px;line-height:1.7;color:var(--text-secondary)}.row{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.status{display:inline-flex;align-items:center;gap:7px;font-size:13px}.segmented{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}.segmented button{flex:1;border:0;border-radius:0}.segmented button[aria-pressed=true]{background:var(--surface-soft);font-weight:600}.instance{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:12px;border-top:1px solid var(--line)}button,select{font:inherit;color:var(--text-primary);background:var(--surface-raised);border:1px solid var(--line);border-radius:8px;min-height:40px;padding:7px 12px}button{display:inline-flex;align-items:center;justify-content:center;gap:6px;cursor:pointer}button:disabled{opacity:.45;cursor:default}.primary{background:var(--accent);color:var(--text-on-solid);margin-top:8px}button:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}ol{list-style:none;margin:0;padding:0;display:grid;gap:20px}li{display:flex;gap:12px}.step{display:grid;place-items:center;border:1px solid var(--line);border-radius:50%;height:26px;width:26px;flex:none;font-size:12px}li strong{font-size:13px}[role=alert]{color:var(--danger)}
+</style>

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

@@ -0,0 +1,118 @@
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+import { apiRequest } from '@/api/client'
+import { UNCONFIGURED_COMPUTER_IMAGE } from '@shared/runner'
+import type { RunnerConfiguration, RunnerRecord } from '@shared/runner'
+
+type Runner = Omit<RunnerRecord, 'tokenHash' | 'sourceOwner'> & {online: boolean}
+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 computerNetwork=ref<'none'|'public-proxy'>('public-proxy')
+const computers=ref(false),computerRuntime=ref<'docker'|'podman'>('docker'),computerImage=ref(''),computerPython=ref(''),hermesSource=ref(''),hermesHome=ref('')
+const artifactRoots = ref(''), insecure = ref(false), configuration = ref<RunnerConfiguration>()
+
+async function refresh() {
+  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))]
+    if(computers.value&&((!!computerImage.value&&!/^sha256:[a-f0-9]{64}$/.test(computerImage.value))||[computerPython.value,hermesSource.value,hermesHome.value].some(path=>!path.startsWith('/'))))throw new Error('请填写镜像完整 ID 和执行电脑上的绝对路径')
+    const result = await apiRequest<{runner:Runner;token:string}>('/api/app/admin/runners', {method:'POST',body:{name:name.value.trim(),sourceNodeId:source.value,allowedProfiles}})
+    configuration.value = {protocol:1,serverURL:web.origin,runnerId:result.runner.id,token:result.token,hermesURL:local.toString(),allowedProfiles,artifactRoots:artifactRoots.value.split('\n').map(p=>p.trim()).filter(Boolean),...(insecure.value?{allowInsecureLan:true}:{}),...(computers.value?{computers:{network:computerNetwork.value,runtime:computerRuntime.value,imageId:computerImage.value||UNCONFIGURED_COMPUTER_IMAGE,python:computerPython.value,hermesSource:hermesSource.value,hermesHome:hermesHome.value}}:{})}
+    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>
+      <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>联网范围<select v-model="computerNetwork"><option value="public-proxy">公网网页(HTTP / HTTPS)</option><option value="none">不联网</option></select></label>
+        <small>公网模式通过受控代理访问,不开放宿主和内网地址。</small>
+        <label>兼容镜像 ID<input v-model.trim="computerImage" placeholder="可在本地虚拟机设置中准备"></label>
+        <label>Hermes Python 路径<input v-model.trim="computerPython" required placeholder="/完整路径/hermes-agent/venv/bin/python"></label>
+        <label>Hermes 源码目录<input v-model.trim="hermesSource" required placeholder="/完整路径/hermes-agent"></label>
+        <label>Hermes 配置目录<input v-model.trim="hermesHome" required placeholder="/完整路径/.hermes"></label>
+        <small>这是外部执行节点的高级配置。本机虚拟机可直接在应用设置中准备,并从 Agent 聊天的电脑面板启用。</small>
+      </fieldset>
+      <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-computer { display: grid; gap: 12px; border: 1px solid var(--line); border-radius: 8px; padding: 12px; min-width: 0; }
+.runner-panel input:not([type=checkbox]),.runner-panel select,.runner-panel textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-raised); color: var(--text-primary); font: inherit; }
+.runner-panel .runner-check { display: flex; align-items: center; }
+.runner-panel code { overflow-wrap: anywhere; line-height: 1.7; }
+.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>

+ 5 - 0
src/client/components/app/SettingsCenterDialog.vue

@@ -10,6 +10,7 @@ import WorkspaceNodesPanel from '@/components/workspace/WorkspaceNodesPanel.vue'
 import WorkspaceVoiceProviders from '@/components/workspace/WorkspaceVoiceProviders.vue'
 import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
 import SystemOverviewPanel from '@/components/app/SystemOverviewPanel.vue'
+import LocalVmSettingsPanel from '@/components/app/LocalVmSettingsPanel.vue'
 import SystemUpdatePanel from '@/components/app/SystemUpdatePanel.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AccountInitialAvatar from '@/components/common/AccountInitialAvatar.vue'
@@ -28,6 +29,7 @@ type SettingsPage =
   | 'system-push'
   | 'system-nodes'
   | 'system-voice'
+  | 'system-local-vm'
   | 'system-update'
 
 type SettingsIcon = 'users' | 'model' | 'settings' | 'panel' | 'sun' | 'link' | 'bell' | 'audio' | 'refresh'
@@ -104,6 +106,7 @@ const accountItems = computed<NavigationItem[]>(() => [
   { key: 'appearance', label: '外观', icon: 'sun' },
 ])
 const systemItems: NavigationItem[] = [
+  { key: 'system-local-vm', label: '本地虚拟机', icon: 'panel' },
   { key: 'system-overview', label: '系统概览', icon: 'panel' },
   { key: 'system-users', label: '用户与权限', icon: 'users' },
   { key: 'system-connection', label: 'Hermes 连接', icon: 'link' },
@@ -126,6 +129,7 @@ const activeTitle = computed(() => ({
   'account-security': '登录与安全',
   'account-mobile': '手机登录',
   appearance: '外观',
+  'system-local-vm': '本地虚拟机',
   'system-overview': '系统概览',
   'system-users': '用户与权限',
   'system-connection': 'Hermes 连接',
@@ -356,6 +360,7 @@ function requestModeSwitch() {
                   </div>
                 </section>
                 <SystemOverviewPanel v-else-if="activePage === 'system-overview' && isAdmin" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @navigate="selectPage" />
+                <LocalVmSettingsPanel v-else-if="activePage === 'system-local-vm' && isAdmin" />
                 <SystemManagementPanel v-else-if="activePage === 'system-users' && isAdmin" section="users" :profiles="profiles" :active="true" @dirty-change="setDirty('system-users', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-connection' && isAdmin" section="connection" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @dirty-change="setDirty('system-connection', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-push' && isAdmin" section="push" :active="true" @dirty-change="setDirty('system-push', $event)" />

+ 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); }

+ 5 - 0
src/client/components/app/WorkspaceShell.vue

@@ -30,6 +30,7 @@ type SettingsPage =
   | 'system-push'
   | 'system-nodes'
   | 'system-voice'
+  | 'system-local-vm'
   | 'system-update'
 
 const SIDEBAR_COLLAPSED_KEY = 'hermes-yaoyao:sidebar-collapsed'
@@ -352,17 +353,21 @@ watch(() => activeNav.value.key, () => {
   profileMenuOpen.value = false
 })
 
+function showLocalVmSettings(){if(props.isAdmin)openSettings('system-local-vm')}
 onMounted(() => {
+  window.addEventListener('yaoyao:local-vm-settings',showLocalVmSettings)
   try { sidebarCollapsed.value = localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1' } catch { /* optional persistence */ }
   document.addEventListener('mousedown', closeMenus)
   document.addEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
   document.addEventListener('keydown', workspaceKeydown)
 })
 onBeforeUnmount(() => {
+  window.removeEventListener('yaoyao:local-vm-settings',showLocalVmSettings)
   document.removeEventListener('mousedown', closeMenus)
   document.removeEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
   document.removeEventListener('keydown', workspaceKeydown)
 })
+defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}})
 </script>
 
 <template>

+ 3 - 1
src/client/components/common/AppIcon.vue

@@ -48,6 +48,7 @@ type IconName =
   | 'pin-off'
   | 'globe'
   | 'bell'
+  | 'monitor'
 
 withDefaults(defineProps<{ name: IconName; size?: number }>(), { size: 18 })
 </script>
@@ -65,7 +66,8 @@ withDefaults(defineProps<{ name: IconName; size?: number }>(), { size: 18 })
     stroke-linejoin="round"
     aria-hidden="true"
   >
-    <template v-if="name === 'chat'">
+    <template v-if="name === 'monitor'"><rect x="3" y="3" width="18" height="13" rx="2"/><path d="M8 21h8M12 16v5"/></template>
+    <template v-else-if="name === 'chat'">
       <path d="M8 18h-3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-5l-6 4z" />
       <path d="M8 9h8M8 13h5" />
     </template>

+ 1 - 0
src/client/components/composer/ComposerShell.vue

@@ -392,6 +392,7 @@ onBeforeUnmount(() => {
 
 defineExpose({
   attachFiles: addFiles,
+  filesSnapshot: () => attachments.value.map(attachment => attachment.file),
   focus: () => textarea.value?.focus(),
   clearAfterSend,
 })

+ 131 - 0
src/client/components/workspace/ComputerPanel.vue

@@ -0,0 +1,131 @@
+<script setup lang="ts">
+import {computed,onBeforeUnmount,onMounted,ref,watch} from 'vue'
+import {apiRequest} from '@/api/client'
+import {createUuid} from '@/utils/id'
+import type {WorkspaceAgent} from '@shared/workspace'
+import type {ComputerControlStatus,ComputerFrame,ComputerInput} from '@shared/computerControl'
+import type {JsonValue} from '@shared/types'
+const props=defineProps<{agents:WorkspaceAgent[];embedded?:boolean;autoTake?:boolean}>(),emit=defineEmits<{close:[];changed:[];controlRequest:[]}>()
+const selected=ref(props.agents[0]?.id??''),dialog=ref<HTMLDialogElement>()
+const state=ref<ComputerControlStatus>({mode:'off'}),frame=ref<ComputerFrame>(),ticket=ref<{controlId:string;token:string}>()
+const controlsOpen=ref(false)
+const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
+const error=ref(''),busy=ref(false),text=ref(''),notes=ref(''),connectionOK=ref(false)
+let takeRequestId:string|undefined
+let timer:ReturnType<typeof setTimeout>|undefined,closed=false,polling=false,lastRenew=0,generation=0
+const labels:Record<ComputerControlStatus['mode'],string>={off:'电脑未运行',idle:'虚拟机空闲 · 仅查看',agent:'Agent 正在操作',pausing:'正在等待当前操作结束',human:'人工控制中',resuming:'正在交还 Agent',error:'电脑需要检查'}
+const mine=computed(()=>!!ticket.value&&ticket.value.controlId===state.value.controlId)
+const canSend=computed(()=>connectionOK.value&&!!frame.value&&mine.value&&state.value.mode==='human'&&frame.value?.generation===state.value.generation)
+const controllable=computed(()=>canSend.value&&!busy.value)
+const endpoint=()=>`/api/app/agents/${encodeURIComponent(selected.value)}/computer`
+async function post(path:string,body:unknown){return apiRequest<any>(endpoint()+path,{method:'POST',body:body as JsonValue})}
+async function refresh(){
+  if(polling||busy.value||closed||!selected.value||document.hidden)return
+  polling=true;const current=++generation,base=endpoint()
+  try{
+    const status=await apiRequest<ComputerControlStatus>(base)
+    if(current!==generation||closed)return
+    state.value=status
+    connectionOK.value=true
+    if(ticket.value&&status.controlId!==ticket.value.controlId&&status.mode!=='pausing')ticket.value=undefined
+    if(mine.value&&Date.now()-lastRenew>8000){await post('/renew',ticket.value);lastRenew=Date.now()}
+    if(status.mode!=='off'){
+      const image=await apiRequest<ComputerFrame>(base+'/frame')
+      if(current===generation&&!closed)frame.value=image
+    }
+  }catch(cause){if(current===generation&&!closed){connectionOK.value=false;error.value=cause instanceof Error?cause.message:'无法读取电脑状态';if([401,403,410].includes(Number((cause as {status?:number}).status))){ticket.value=undefined;frame.value=undefined;state.value={mode:'off'}}}}
+  finally{polling=false}
+}
+async function run(action:()=>Promise<void>){
+  if(busy.value)return;generation++;busy.value=true;error.value=''
+  try{await action()}catch(cause){error.value=cause instanceof Error?cause.message:'电脑操作失败'}finally{busy.value=false;await refresh();void flushInputs()}
+}
+function take(){return run(async()=>{takeRequestId??=createUuid();const value=await post('/take',{requestId:takeRequestId});ticket.value={controlId:value.controlId,token:value.token};state.value=value;lastRenew=0})}
+const inputs:ComputerInput[]=[];let flushing=false,pendingClick:ComputerInput|undefined,clickTimer:ReturnType<typeof setTimeout>|undefined
+function send(action:ComputerInput){
+  if(!canSend.value||closed)return
+  if(pendingClick){inputs.push(pendingClick);pendingClick=undefined;clearTimeout(clickTimer)}
+  const last=inputs.at(-1)
+  if(action.kind==='text'&&last?.kind==='text'&&last.text.length+action.text.length<=16000)last.text+=action.text
+  else if(inputs.length<64)inputs.push(action)
+  else {error.value='输入过快,请稍后再试';return}
+  void flushInputs()
+}
+async function flushInputs(){
+  if(flushing||busy.value||!inputs.length||closed)return
+  if(!canSend.value||!frame.value||!ticket.value){inputs.length=0;return}
+  flushing=true
+  const action=inputs.shift()!,image=frame.value,credentials=ticket.value
+  try{await run(async()=>{await post('/input',{...credentials,requestId:createUuid(),generation:image.generation,frameId:image.id,action})});if(error.value)inputs.length=0}
+  finally{flushing=false;if(inputs.length)setTimeout(()=>void flushInputs(),0)}
+}
+function point(event:MouseEvent|PointerEvent){const r=(event.currentTarget as HTMLImageElement).getBoundingClientRect(),f=frame.value!;return {x:Math.min(f.width-1,Math.max(0,Math.floor((event.clientX-r.left)*f.width/r.width))),y:Math.min(f.height-1,Math.max(0,Math.floor((event.clientY-r.top)*f.height/r.height)))}}
+let dragStart:{x:number;y:number;clientX:number;clientY:number}|undefined,dragged=false
+function down(event:PointerEvent){if(!canSend.value||event.button!==0)return;const p=point(event);dragStart={...p,clientX:event.clientX,clientY:event.clientY};(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)}
+function up(event:PointerEvent){if(!dragStart)return;const start=dragStart;dragStart=undefined;if(!canSend.value)return;if(Math.hypot(event.clientX-start.clientX,event.clientY-start.clientY)>5){const end=point(event);dragged=true;send({kind:'drag',fromX:start.x,fromY:start.y,toX:end.x,toY:end.y})}}
+function wheel(event:WheelEvent){if(!canSend.value)return;event.preventDefault();send({kind:'scroll',direction:Math.abs(event.deltaX)>Math.abs(event.deltaY)?event.deltaX>0?'right':'left':event.deltaY>0?'down':'up',amount:Math.min(20,Math.max(1,Math.ceil(Math.abs(event.deltaY||event.deltaX)/80)))})}
+function keydown(event:KeyboardEvent){if(!canSend.value)return;event.preventDefault();event.stopPropagation();if(event.key==='Escape'){(event.currentTarget as HTMLElement).blur();return}const modifiers=[event.ctrlKey?'ctrl':'',event.altKey?'alt':'',event.shiftKey?'shift':'',event.metaKey?'super':''].filter(Boolean);if(event.key.length===1&&!event.ctrlKey&&!event.altKey&&!event.metaKey)send({kind:'text',text:event.key});else if(!['Control','Alt','Shift','Meta'].includes(event.key))send({kind:'key',key:({Enter:'Return',ArrowUp:'Up',ArrowDown:'Down',ArrowLeft:'Left',ArrowRight:'Right',Backspace:'BackSpace'} as Record<string,string>)[event.key]??event.key,modifiers})}
+function click(event:MouseEvent){
+  if(dragged){dragged=false;return}
+  if(!canSend.value||!frame.value)return
+  const rect=(event.currentTarget as HTMLImageElement).getBoundingClientRect()
+  const action:ComputerInput={kind:'click',x:Math.min(frame.value.width-1,Math.max(0,Math.floor((event.clientX-rect.left)*frame.value.width/rect.width))),y:Math.min(frame.value.height-1,Math.max(0,Math.floor((event.clientY-rect.top)*frame.value.height/rect.height))),button:event.button===2?'right':'left',count:event.detail>1?2:1}
+  if(event.button===2||event.detail>1){clearTimeout(clickTimer);pendingClick=undefined;send(action)}
+  else {if(pendingClick){const old=pendingClick;pendingClick=undefined;clearTimeout(clickTimer);send(old)}pendingClick=action;clickTimer=setTimeout(()=>{const next=pendingClick;pendingClick=undefined;if(next)send(next)},180)}
+}
+async function giveBack(){if(ticket.value){await post('/giveback',{...ticket.value,notes:notes.value});ticket.value=undefined;takeRequestId=undefined;notes.value=''}}
+async function close(){if(busy.value)return;await run(giveBack);if(!ticket.value){closed=true;if(!props.embedded)dialog.value?.close();emit('close')}}
+async function cycle(){await refresh();if(!closed)timer=setTimeout(()=>void cycle(),1200)}
+watch(selected,()=>{inputs.length=0;connectionOK.value=false;generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value='';void refresh()})
+onMounted(()=>{if(!props.embedded)dialog.value?.showModal();void cycle();if(props.autoTake)void take()})
+onBeforeUnmount(()=>{inputs.length=0;pendingClick=undefined;clearTimeout(clickTimer);closed=true;generation++;clearTimeout(timer)})
+defineExpose({take,releaseControl:async()=>{await run(giveBack);if(ticket.value)throw new Error(error.value||'请先交还控制权')},hasControl:()=>!!ticket.value})
+</script>
+<template>
+  <Teleport to="body" :disabled="embedded">
+    <component :is="embedded?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded}" aria-label="隔离电脑" @cancel.prevent="close">
+      <header>
+        <div><strong>隔离电脑</strong><small>{{ labels[state.mode] }}</small></div>
+        <select v-if="agents.length>1" v-model="selected" aria-label="选择电脑" :disabled="!!ticket"><option v-for="agent in agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option></select>
+        <span v-else-if="!embedded">{{ agents[0]?.name }}</span>
+        <button v-if="embedded&&!ticket" :disabled="busy||state.mode==='human'" @click="emit('controlRequest')">接管电脑</button>
+        <button v-if="embedded&&mine" @click="controlsOpen=!controlsOpen">键盘与操作</button>
+        <button v-if="embedded&&mine" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">交还</button>
+        <button v-if="!embedded" type="button" :disabled="busy" @click="close">{{ ticket ? '交还并关闭' : '关闭' }}</button>
+      </header>
+      <div class="computer-screen" :class="{controlling:canSend}">
+        <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" alt="隔离电脑当前画面" draggable="false" tabindex="0" title="点按后可使用键盘、滚轮和拖动;按 Esc 退出键盘控制" @pointerdown="down" @pointerup="up" @pointercancel="dragStart=undefined" @wheel="wheel" @keydown="keydown" @click="click" @contextmenu.prevent="click">
+        <p v-else>{{ state.mode==='off' ? '打开电脑后可查看桌面。' : '正在读取电脑画面…' }}</p>
+        <span v-if="frame&&(!mine||frame.generation!==state.generation)" class="view-label">{{mine?'正在同步画面':state.mode==='off'?'上次画面':'仅查看'}}</span>
+      </div>
+      <p v-if="error" class="computer-error" role="alert">{{ error }}</p>
+      <footer v-if="!embedded||controlsOpen">
+        <p v-if="selectedAgent?.computerEnvironmentId">共享电脑:{{ selectedAgent.computerEnvironmentName }}。成员共用工作文件和浏览器资料,同一时间只有一名操作者。</p>
+        <p v-if="!ticket">查看不会输入任何内容。接管后 Agent 会暂停,已有操作结束后才允许人工操作。</p>
+        <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy" @click="embedded?emit('controlRequest'):take()">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
+        <template v-if="mine">
+          <button type="button" @click="controlsOpen=!controlsOpen">{{controlsOpen?'收起键盘':'键盘与操作'}}</button>
+          <section v-if="controlsOpen" class="manual-input">
+          <div class="key-row"><button v-for="key in ['Return','Tab','Escape','BackSpace','Up','Down','Left','Right']" :key="key" type="button" :disabled="!controllable" @click="send({kind:'key',key})">{{ ({Return:'回车',Escape:'Esc',BackSpace:'退格',Up:'↑',Down:'↓',Left:'←',Right:'→'} as Record<string,string>)[key]||key }}</button></div>
+          <div class="key-row"><button :disabled="!controllable" @click="send({kind:'key',key:'a',modifiers:['ctrl']})">全选</button><button :disabled="!controllable" @click="send({kind:'key',key:'c',modifiers:['ctrl']})">复制</button><button :disabled="!controllable" @click="send({kind:'key',key:'v',modifiers:['ctrl']})">粘贴</button></div>
+          <label>输入到电脑<textarea v-model="text" rows="2" maxlength="16000" placeholder="输入文字后点击发送" :disabled="!controllable" /></label>
+          <button type="button" :disabled="!controllable||!text" @click="send({kind:'text',text});text=''">输入文字</button>
+          </section>
+          <label>交还说明<textarea v-model="notes" rows="2" maxlength="4000" placeholder="告诉 Agent 你完成了什么,以及接下来需要做什么" /></label>
+          <button type="button" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">{{ state.canResume ? '交还 Agent 并继续' : '结束控制' }}</button>
+          <small>控制只属于当前页面。页面断开后控制权会到期,任务不会自行恢复。</small>
+        </template>
+      </footer>
+    </component>
+  </Teleport>
+</template>
+<style scoped>
+.computer-panel.embedded{position:static;width:100%;max-height:none;height:100%;margin:0;border:0;border-radius:0;overflow:auto}.computer-panel.embedded{display:flex;flex-direction:column}.embedded .computer-screen{flex:1;min-height:150px}.embedded .computer-screen img{max-height:calc(100dvh - 250px);object-fit:contain}.embedded footer{max-height:45vh;overflow:auto}.manual-input{display:grid;gap:10px}
+.computer-panel{width:min(1120px,calc(100vw - 32px));max-height:calc(100dvh - 32px);padding:0;border:1px solid var(--line);border-radius:16px;background:var(--surface);color:var(--text-primary);overflow:auto}
+.computer-panel::backdrop{background:rgba(0,0,0,.45)}header{display:flex;align-items:center;gap:14px;padding:14px 18px;border-bottom:1px solid var(--line)}header div{display:grid;gap:4px;margin-right:auto}small{font-size:12px;color:var(--text-muted);line-height:1.5}
+button,select,textarea,input{font:inherit;border:1px solid var(--line);border-radius:8px;background:var(--surface-raised);color:var(--text-primary)}button,select{min-height:44px;padding:8px 12px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,select:focus-visible,textarea:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+.computer-screen{position:relative;display:flex;align-items:center;justify-content:center;min-height:220px;background:#14181e}.computer-screen img{max-width:100%;max-height:58vh;width:auto;height:auto;user-select:none}.controlling img{cursor:crosshair}.computer-screen img:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.computer-screen p{color:#d8dee7}.view-label{position:absolute;top:10px;left:12px;padding:4px 8px;border-radius:6px;background:rgba(0,0,0,.65);color:white;font-size:12px;pointer-events:none}
+footer{padding:16px 18px;display:grid;gap:12px}footer p{margin:0;font-size:13px;color:var(--text-secondary);line-height:1.6}label{display:grid;gap:6px;font-size:13px}textarea{padding:10px;min-width:0;resize:vertical}.key-row{display:flex;gap:6px;flex-wrap:wrap}.computer-error{color:var(--danger);padding:0 18px;font-size:13px}
+.sharing-settings{display:grid;gap:12px;padding:12px;border:1px solid var(--line);border-radius:10px}.sharing-settings input:not([type=checkbox]){min-height:44px;padding:8px 10px}.share-option{display:flex;align-items:center;gap:8px;min-height:44px}.share-option input{width:18px;height:18px;flex:none}fieldset{border:1px solid var(--line);border-radius:8px}
+@media(max-width:600px){.computer-panel{width:100vw;max-height:100dvh;border-radius:0}header{flex-wrap:wrap;padding:12px}footer{padding:12px}.computer-screen img{max-height:48vh}}
+</style>

File diff suppressed because it is too large
+ 68 - 0
src/client/components/workspace/LocalVmChatPanel.vue


+ 32 - 0
src/client/components/workspace/LocalVmWorkspace.vue

@@ -0,0 +1,32 @@
+<script setup lang="ts">
+import {ref,computed} from 'vue'
+import type {WorkspaceAgent} from '@shared/workspace'
+import ComputerPanel from './ComputerPanel.vue'
+const props=defineProps<{agents:WorkspaceAgent[];primary:string}>(),emit=defineEmits<{close:[]}>()
+const slots=ref([props.primary,props.agents.find(a=>a.id!==props.primary)?.id??''])
+const panels:Array<InstanceType<typeof ComputerPanel>|null>=[]
+const busy=ref(false),error=ref(''),controlled=ref<number>()
+const choices=computed(()=>props.agents.filter(a=>!a.computerEnvironmentId))
+async function release(){for(const panel of panels)if(panel?.hasControl())await panel.releaseControl();controlled.value=undefined}
+async function take(index:number){
+ if(busy.value)return;busy.value=true;error.value=''
+ try{await release();await panels[index]?.take();if(panels[index]?.hasControl())controlled.value=index}catch(e){error.value=e instanceof Error?e.message:'切换控制失败'}finally{busy.value=false}
+}
+async function select(index:number,value:string){if(busy.value)return;busy.value=true;error.value='';try{await release();slots.value[index]=value}catch(e){error.value=String(e)}finally{busy.value=false}}
+async function close(){if(busy.value)return false;busy.value=true;try{await release();emit('close');return true}catch(e){error.value=String(e);return false}finally{busy.value=false}}
+defineExpose({close})
+</script>
+<template>
+ <main class="local-vm-workspace" aria-label="双桌面工作区">
+  <header><div><strong>本地虚拟机 · 双桌面</strong><p>同时查看两个 Agent 的桌面,每次只控制其中一台。</p></div><button :disabled="busy" @click="close">交还并关闭</button></header>
+  <p v-if="error" role="alert">{{error}}</p>
+  <div class="panes"><section v-for="(_,index) in slots" :key="index" class="pane" :class="{controlled:controlled===index}">
+   <label>桌面 {{index+1}}<select :value="slots[index]" :disabled="busy" :aria-label="`选择桌面 ${index+1}`" @change="select(index,($event.target as HTMLSelectElement).value)"><option value="">选择 Agent</option><option v-for="agent in choices" :key="agent.id" :value="agent.id" :disabled="slots[1-index]===agent.id">{{agent.name}}</option></select></label>
+   <ComputerPanel v-if="slots[index]&&choices.some(a=>a.id===slots[index])" :key="slots[index]" :ref="el=>panels[index]=el as InstanceType<typeof ComputerPanel>|null" embedded :agents="choices.filter(a=>a.id===slots[index])" @control-request="take(index)"/>
+   <p v-else class="empty">选择另一位 Agent。打开工作区不会创建或启动虚拟机。</p>
+  </section></div>
+ </main>
+</template>
+<style scoped>
+.local-vm-workspace{display:flex;flex-direction:column;width:100%;height:100%;min-height:0;max-height:none;margin:0;padding:0;border:0;background:var(--surface);color:var(--text-primary)}.local-vm-workspace::backdrop{background:#0009}header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid var(--line)}p{font-size:12px;color:var(--text-secondary);line-height:1.6;margin:6px 0 0}.panes{display:grid;grid-template-columns:1fr 1fr;flex:1;min-height:0;gap:12px;padding:12px;background:var(--surface-soft)}.pane{display:flex;min-width:0;min-height:0;flex-direction:column;background:var(--surface);border:1px solid var(--line);border-radius:10px;overflow:hidden}.pane>label{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 16px;font-size:13px;border-bottom:1px solid var(--line)}.controlled{outline:2px solid var(--accent);outline-offset:-2px}button,select{min-height:40px;padding:7px 12px;font:inherit;border:1px solid var(--line);border-radius:8px;color:inherit;background:var(--surface-raised)}button:disabled,select:disabled{opacity:.5}button:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.empty{padding:30px}.panes:has(+[role=alert]){height:auto}[role=alert]{color:var(--danger);padding:0 20px}@media(max-width:800px){.panes{grid-template-columns:1fr;height:auto}.local-vm-workspace{overflow:auto}.pane{min-height:70vh}}
+</style>

+ 33 - 0
src/client/components/workspace/TaskPlan.vue

@@ -0,0 +1,33 @@
+<script setup lang="ts">
+import type { AgentAssignment, AgentGoal } from '@shared/agentTasks'
+import type { WorkspaceAgent } from '@shared/workspace'
+defineProps<{ goal?: AgentGoal; assignments: AgentAssignment[]; agents: WorkspaceAgent[] }>()
+defineEmits<{stop:[];resume:[]}>()
+const labels: Record<string,string> = {pending:'待执行',running:'进行中',review:'待复核',complete:'已完成',failed:'执行失败',blocked:'遇到阻碍',cancelling:'正在停止',cancelled:'已停止',waiting:'需要补充信息'}
+</script>
+<template>
+  <details v-if="goal || assignments.length" class="task-plan">
+    <summary>任务进度 <span v-if="goal">{{ labels[goal.status] }}</span></summary>
+    <ol v-if="assignments.length">
+      <li v-for="item in assignments" :key="item.id">
+        <div><strong>{{ item.title }}</strong><span>{{ labels[item.status] }}</span></div>
+        <small>{{ agents.find(a => a.id === item.agentId)?.name || '成员不可用' }}<template v-if="agents.find(a => a.id === item.agentId)?.temporaryGoalId"> · 临时助手{{ agents.find(a => a.id === item.agentId)?.archived ? (agents.find(a => a.id === item.agentId)?.cleanupState === 'pending' ? '(已退役,等待清理)' : '(已退役)') : '' }}</template></small>
+        <p v-if="item.review">{{ item.review }}</p>
+      </li>
+    </ol>
+    <p v-if="goal?.result" class="result">{{ goal.result }}</p>
+    <ul v-if="goal?.acceptanceCriteria.length" class="criteria">
+      <li v-for="(criterion,index) in goal.acceptanceCriteria" :key="index">
+        <span>{{ goal.checks?.some(check => check.criterion === index && check.passed) ? '已验收' : '待验收' }}</span> {{ criterion }}
+      </li>
+    </ul>
+    <button v-if="goal && ['running','review','waiting'].includes(goal.status)" type="button" @click="$emit('stop')">停止任务</button>
+    <button v-if="goal && ['blocked','waiting','cancelled'].includes(goal.status)" type="button" @click="$emit('resume')">继续任务</button>
+  </details>
+</template>
+<style scoped>
+.task-plan{margin:0 18px 10px;border:1px solid var(--line);border-radius:12px;padding:0 14px;font-size:13px;max-height:34vh;overflow:auto}
+summary{min-height:44px;display:flex;align-items:center;gap:12px;cursor:pointer;list-style:revert}summary span,small{color:var(--text-muted)}
+button{min-height:44px;padding:8px 12px;margin-bottom:10px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);cursor:pointer}
+ol{padding-left:18px;margin-top:0}li{padding:7px 0}li div{display:flex;gap:12px;justify-content:space-between}li div span{white-space:nowrap;color:var(--text-muted)}p{white-space:pre-wrap;line-height:1.6}.criteria{padding-left:18px}.criteria span{font-size:12px;color:var(--text-muted)}summary:focus-visible{outline:2px solid var(--text-primary);outline-offset:3px}
+</style>

+ 144 - 24
src/client/views/ConversationsView.vue

@@ -2,10 +2,16 @@
 import { publishServerIdentity } from '@/api/serverIdentity'
 import type { ServerIdentity } from '@shared/serverIdentity'
 import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
-import { useRoute, useRouter } from 'vue-router'
+import { useRoute, useRouter, onBeforeRouteUpdate, onBeforeRouteLeave } from 'vue-router'
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
 import RemoteAgentPicker from '@/components/workspace/RemoteAgentPicker.vue'
 import ConversationList from '@/components/workspace/ConversationList.vue'
+import LocalVmChatPanel from '@/components/workspace/LocalVmChatPanel.vue'
+import LocalVmWorkspace from '@/components/workspace/LocalVmWorkspace.vue'
+import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
+import TaskPlan from '@/components/workspace/TaskPlan.vue'
+import type { AgentAssignment } from '@shared/agentTasks'
+import type { WorkspaceTask } from '@shared/workspace'
 import FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
 import type { SidebarItem } from '@/components/app/types'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
@@ -27,7 +33,7 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import { workspaceAvatarMembers, workspaceAvatarState, workspaceConversationItem, workspaceMessagesToUi } from '@/components/workspace/viewModels'
 import { useAuthStore } from '@/stores/auth'
 import { useThemeStore } from '@/stores/theme'
-import { apiRequest } from '@/api/client'
+import { apiRequest, ApiError } from '@/api/client'
 import { updateProfileIdentity, type ProfileIdentityInput } from '@/api/profiles'
 import { encodeAgentAvatar, randomAgentIdentity } from '@shared/agentIdentity'
 import type { JsonValue } from '@shared/types'
@@ -53,6 +59,10 @@ const active = ref<Conversation>(),
   run = ref<Run | null>(null),
   interactions = ref<Interaction[]>([]),
   context = ref<Record<string, unknown> | null>(null)
+const activeTask = ref<WorkspaceTask | null>(null), tasks = ref<WorkspaceTask[]>([]), assignments = ref<AgentAssignment[]>([])
+const taskFiles = new Map<string,File[]>()
+const taskQuotes = new Map<string,UiMessage | null>()
+const composerKey = computed(() => `${auth.user?.id}:${active.value?.id}${activeTask.value ? `:${activeTask.value.id}` : ''}`)
 const text = ref(''),
   error = ref(''),
   busy = ref(false),
@@ -134,6 +144,8 @@ const form = reactive({
   name: '',
   avatar: '',
   instructions: '',
+  execution: 'profile' as 'profile'|'computer',
+  canManageTeam: false,
   source: '',
   memberIds: [] as string[],
   memberRoles: {} as Record<string, WorkspaceMemberRole>,
@@ -152,7 +164,7 @@ function applyPresetRoles(preset: TeamPreset) {
   form.administratorId = form.memberIds[Math.max(0, preset.roles.findIndex(role => role.host))] ?? ''
 }
 function choosePreset(preset?: TeamPreset) {
-  const available = agents.value.filter(a => !a.archived)
+  const available = agents.value.filter(a => !a.archived && !a.temporaryGoalId)
   if (preset && available.length < preset.roles.length) return
   selectedPresetId.value = preset?.id ?? 'custom'
   Object.assign(form, { name: preset?.name ?? '', instructions: preset?.instructions ?? '', memberIds: preset ? available.slice(0, preset.roles.length).map(a => a.id) : [], memberRoles: {}, administratorId: '', mode: 'host', autoReplyIds: [], maxReplyRounds: 3 })
@@ -174,6 +186,16 @@ let cursor = 0,
   pendingRequestId: string | undefined,
   pendingFingerprint = ''
 const selected = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
+const selectedTask = computed(() => typeof route.query.taskId === 'string' ? route.query.taskId : '')
+const shell=ref<InstanceType<typeof WorkspaceShell>>(),vmWorkspace=ref<InstanceType<typeof LocalVmWorkspace>>(),desktopViewer=ref<InstanceType<typeof ComputerPanel>>()
+const computerOpen=ref(false),computerDockOpen=ref(false),twoDesktops=ref(false),desktopAgent=ref<Agent>()
+async function leaveComputer(){
+  if(twoDesktops.value&&vmWorkspace.value&&!await vmWorkspace.value.close())return false
+  if(computerOpen.value&&desktopViewer.value){try{await desktopViewer.value.releaseControl();computerOpen.value=false}catch{return false}}
+  return true
+}
+onBeforeRouteUpdate(leaveComputer);onBeforeRouteLeave(leaveComputer)
+const computerCandidates=computed(()=>agents.value.filter(agent=>!agent.archived&&(active.value?.memberIds.includes(agent.id)||(!!agent.temporaryGoalId&&agent.temporaryGoalId===activeTask.value?.goal?.id))))
 const members = computed(() => agents.value.filter((a) => active.value?.memberIds.includes(a.id)))
 const isAgentDialog = computed(() => dialog.value === 'agent' || dialog.value === 'editAgent')
 const body = (v: unknown) => v as JsonValue
@@ -211,9 +233,12 @@ async function load(id = selected.value, append = false) {
     run.value = null
     interactions.value = []
     context.value = null
+    activeTask.value = null;tasks.value = [];assignments.value = []
     return
   }
   const own = ++generation
+  const previousComposer = composerKey.value
+  const requestedTask = selectedTask.value || (active.value?.id === id ? activeTask.value?.id : undefined)
   if (!append) loading.value = true
   try {
     const r = await apiRequest<{
@@ -223,10 +248,14 @@ async function load(id = selected.value, append = false) {
       interactions: Interaction[]
       context: Record<string, unknown> | null
       hiddenMessageIds?: string[]
-    }>(`/api/app/conversations/${id}`)
+      task?: WorkspaceTask | null
+      tasks?: WorkspaceTask[]
+      assignments?: AgentAssignment[]
+    }>(`/api/app/conversations/${id}${requestedTask ? `?taskId=${encodeURIComponent(requestedTask)}` : ''}`)
     if (disposed || own !== generation) return
     const atBottom = timeline.value?.isFollowingBottom() ?? true
     active.value = r.conversation
+    activeTask.value = r.task ?? null;tasks.value = r.tasks ?? [];assignments.value = r.assignments ?? []
     messages.value = append
       ? [...new Map([...messages.value, ...r.messages].map((m) => [m.id, m])).values()].sort(
           (a, b) => a.seq - b.seq,
@@ -236,6 +265,22 @@ async function load(id = selected.value, append = false) {
     run.value = r.run
     interactions.value = r.interactions
     context.value = r.context
+    if (previousComposer !== composerKey.value) {
+      taskFiles.set(previousComposer,composer.value?.filesSnapshot() ?? [])
+      taskQuotes.set(previousComposer,quoted.value)
+      if (activeTask.value && !requestedTask) {
+        const legacy = `hermes-yaoyao:composer:group:${auth.user?.id}:${active.value.id}`
+        const current = `hermes-yaoyao:composer:group:${composerKey.value}`
+        try {
+          const draft = localStorage.getItem(legacy)
+          if (draft && !localStorage.getItem(current)) { localStorage.setItem(current,draft);localStorage.removeItem(legacy) }
+        } catch { /* Leave the original draft intact when browser storage is unavailable. */ }
+      }
+      await nextTick()
+      const saved = taskFiles.get(composerKey.value)
+      if (saved?.length) await composer.value?.attachFiles(saved)
+      quoted.value = taskQuotes.get(composerKey.value) ?? null
+    }
     if (!append) older.value = r.messages.length === 100
     if (!append || atBottom) {
       await nextTick()
@@ -243,19 +288,55 @@ async function load(id = selected.value, append = false) {
       await markRead()
     }
   } catch (e) {
-    error.value = e instanceof Error ? e.message : '加载失败'
+    if (own === generation && requestedTask && e instanceof ApiError && e.status === 404) {
+      try {
+        const current = await apiRequest<{tasks:WorkspaceTask[]}>(`/api/app/conversations/${id}/tasks`)
+        const fallback = current.tasks[0]
+        if (own === generation && fallback && fallback.id !== requestedTask) {
+          await router.replace({query:{...route.query,taskId:fallback.id}})
+          return
+        }
+      } catch { /* A deleted conversation still reports its original error. */ }
+    }
+    if (own === generation) error.value = e instanceof Error ? e.message : '加载失败'
   } finally {
     if (own === generation) loading.value = false
   }
 }
 async function markRead() {
   const c = active.value
-  if (!c || c.readSeq >= c.lastSeq) return
-  await apiRequest(`/api/app/conversations/${c.id}/read`, {
+  const read = activeTask.value ?? c
+  if (!c || !read || read.readSeq >= read.lastSeq) return
+  await apiRequest(`/api/app/conversations/${c.id}/read${activeTask.value ? `?taskId=${activeTask.value.id}` : ''}`, {
     method: 'PUT',
-    body: { seq: c.lastSeq },
+    body: { seq: read.lastSeq },
   })
-  c.readSeq = c.lastSeq
+  read.readSeq = read.lastSeq
+}
+async function stopTask() {
+  if (!active.value || !activeTask.value) return
+  try {
+    await apiRequest(`/api/app/conversations/${active.value.id}/tasks/${activeTask.value.id}/stop`,{method:'POST',body:{}})
+    await load(undefined,true)
+  } catch(cause) {error.value=cause instanceof Error?cause.message:'停止任务失败'}
+}
+async function resumeTask() {
+  if (!active.value || !activeTask.value) return
+  try {
+    await apiRequest(`/api/app/conversations/${active.value.id}/tasks/${activeTask.value.id}/resume`,{method:'POST',body:{requestId:createUuid()}})
+    await load(undefined,true)
+  } catch(cause) {error.value=cause instanceof Error?cause.message:'恢复任务失败'}
+}
+function openTaskLink(event: MouseEvent) {
+  if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
+  const anchor=(event.target as Element)?.closest?.('a[href]')
+  if (!anchor) return
+  try {
+    const url=new URL(anchor.getAttribute('href') || '',window.location.origin)
+    if(url.origin!==window.location.origin || !/^\/conversations\/[^/]+$/.test(url.pathname) || !url.searchParams.get('taskId'))return
+    event.preventDefault();event.stopPropagation()
+    void router.push(url.pathname+url.search)
+  } catch { /* Leave other links to their normal handler. */ }
 }
 async function poll() {
   try {
@@ -281,7 +362,6 @@ async function select(id: string) {
   files.value = []
   mentions.value = []
   text.value = ''
-  quoted.value = null
   preview.value = null
   mediaIndex.value = null
   pendingRequestId = undefined
@@ -296,6 +376,8 @@ async function openDialog(kind: NonNullable<typeof dialog.value>) {
     name: '',
     avatar: kind === 'agent' ? encodeAgentAvatar(randomAgentIdentity('new-agent', 'Agent')) : '',
     instructions: '',
+    execution: 'profile' as 'profile'|'computer',
+  canManageTeam: false,
     source: '',
     memberIds: [],
     memberRoles: {},
@@ -352,7 +434,7 @@ async function save() {
   busy.value = true
   error.value = ''
   try {
-    const fields = { name: form.name, ...(isAgentDialog.value ? { avatar: form.avatar } : {}), instructions: form.instructions }
+    const fields = { name: form.name, ...(isAgentDialog.value ? { avatar: form.avatar, canManageTeam: form.canManageTeam, execution:form.execution } : {}), instructions: form.instructions }
     if (dialog.value === 'agent') {
       const [nodeId, profile] = JSON.parse(form.source)
       const result = await apiRequest<{ agent: Agent }>('/api/app/agents', {
@@ -418,7 +500,7 @@ async function send() {
   if (!c || busy.value || (!text.value.trim() && !files.value.length)) return
   busy.value = true
   error.value = ''
-  const fingerprint = JSON.stringify([c.id, text.value, mentions.value, files.value.map(f => f.id)])
+  const fingerprint = JSON.stringify([c.id, activeTask.value?.id, text.value, mentions.value, files.value.map(f => f.id)])
   if (!pendingRequestId || pendingFingerprint !== fingerprint) {
     pendingRequestId = createUuid()
     pendingFingerprint = fingerprint
@@ -428,6 +510,7 @@ async function send() {
       method: 'POST',
       body: {
         requestId: pendingRequestId,
+        ...(activeTask.value ? { taskId: activeTask.value.id } : {}),
         content: text.value,
         mentionIds: mentions.value,
         fileIds: files.value.map((f) => f.id),
@@ -497,10 +580,12 @@ async function respond(i: Interaction, answer: string) {
 async function loadOlder() {
   const c = active.value
   if (!c) return
+  const taskId = activeTask.value?.id, own = generation
   try {
     const r = await apiRequest<{ messages: Message[] }>(
-      `/api/app/conversations/${c.id}/messages?before=${messages.value[0]?.seq ?? 0}`,
+      `/api/app/conversations/${c.id}/messages?before=${messages.value[0]?.seq ?? 0}${activeTask.value ? `&taskId=${activeTask.value.id}` : ''}`,
     )
+    if (own !== generation || active.value?.id !== c.id || activeTask.value?.id !== taskId) return
     messages.value = [...r.messages, ...messages.value]
     older.value = r.messages.length === 100
   } catch (e) {
@@ -521,7 +606,13 @@ function rendered(m: Message) {
   )
 }
 
-watch(selected, () => void load())
+watch([selected,selectedTask], () => {
+  taskFiles.set(composerKey.value, composer.value?.filesSnapshot() ?? [])
+  taskQuotes.set(composerKey.value, quoted.value)
+  files.value = [];mentions.value = [];text.value = ''
+  pendingRequestId = undefined;uploadedSources = [];uploadedReferences = []
+  void load()
+})
 onMounted(async () => {
   void auth.refreshProfileAvatars().catch(() => undefined)
   try {
@@ -540,7 +631,7 @@ onBeforeUnmount(() => {
 })
 </script>
 <template>
-  <WorkspaceShell :server-name="auth.serverIdentity?.displayName"
+  <WorkspaceShell ref="shell" :server-name="auth.serverIdentity?.displayName"
     :user-name="auth.user?.username"
     :user-avatar="auth.user?.avatar"
     :profiles="auth.profiles"
@@ -570,11 +661,12 @@ onBeforeUnmount(() => {
     <template #mobile-sidebar
       ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
     /></template>
-    <section class="workspace-chat" aria-label="聊天">
+    <div v-show="!twoDesktops" class="conversation-with-computer">
+    <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
       <MessageTimeline ref="timeline" :messages="uiMessages" :title="active?.name || 'Bot 模式'"
         :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
         :loading="loading" :has-older="older && !!active" :connected="!error" :synced="!loading"
-        :show-tools="showThinking" :allow-branch="false" :thinking="!!active?.activeRunId"
+        :show-tools="showThinking" :allow-branch="false" :thinking="!!run && ['running','waiting','uncertain'].includes(run.status)"
         :agent-avatars="Object.fromEntries(agents.map(a => [a.id, a.avatar]))"
         :agent-states="Object.fromEntries(members.map(a => [a.id, workspaceAvatarState(active, a.id)]))"
         :mention-names="members.map(a => a.name)"
@@ -587,24 +679,34 @@ onBeforeUnmount(() => {
         <template #header-leading><button v-if="active" class="workspace-list-back icon-button" aria-label="返回 Bot 列表" @click="router.push('/conversations')"><AppIcon name="chevron-left" /></button></template>
         <template #header-actions>
           <div v-if="active" class="header-actions">
+            <button type="button" class="icon-button" aria-label="电脑" title="电脑" @click="computerDockOpen=!computerDockOpen"><AppIcon name="monitor"/></button>
+            <select v-if="active.kind === 'group' && tasks.length" class="task-picker" aria-label="当前任务" :value="activeTask?.id" @change="router.push({query:{...route.query,taskId:($event.target as HTMLSelectElement).value}})">
+              <option v-for="task in tasks" :key="task.id" :value="task.id">{{ task.title }}</option>
+            </select>
             <button class="icon-button" :aria-label="active.pinned ? '取消置顶' : '置顶聊天'" :title="active.pinned ? '取消置顶' : '置顶聊天'" @click="action('pin')"><AppIcon :name="active.pinned ? 'pin-off' : 'pin'" /></button>
             <button class="icon-button" aria-label="聊天设置" title="聊天设置" @click="openDialog(active.kind === 'direct' ? 'editAgent' : 'editGroup')"><AppIcon name="settings" /></button>
             <button class="icon-button" :aria-label="active.archived ? '恢复聊天' : '归档聊天'" :title="active.archived ? '恢复聊天' : '归档聊天'" @click="action('archive')"><AppIcon name="archive" /></button>
           </div>
         </template>
       </MessageTimeline>
+      <p v-if="run?.status === 'queued'" class="task-queue-status" role="status">正在等待可用 Agent</p>
+      <TaskPlan :goal="activeTask?.goal" :assignments="assignments" :agents="agents" @stop="stopTask" @resume="resumeTask" />
       <p v-if="error" class="error" role="alert">{{ error }}<button class="icon-button" @click="error = ''" aria-label="关闭错误"><AppIcon name="close" /></button></p>
-      <ComposerShell v-if="active" :key="active.id" ref="composer" mode="group" :draft-key="`${auth.user?.id}:${active.id}`"
+      <ComposerShell v-if="active" :key="composerKey" ref="composer" mode="group" :draft-key="composerKey"
         :disabled="active.archived || loading || active.id !== selected" :sending="busy" stop-while-running :streaming="!!active.activeRunId"
         :tool-trace-visible="showThinking" :reference="reference" :context-used="Number(context?.usedTokens || 0)" :context-limit="Number(context?.limitTokens || 0)"
         :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
         @send="sendFromComposer" @stop="control('stop')" @tool-trace-toggle="showThinking = !showThinking" @clear-reference="quoted = null" @error="error = $event" />
     </section>
+    <LocalVmChatPanel v-if="computerDockOpen" :active="!twoDesktops" :agents="computerCandidates" :is-admin="auth.user?.role === 'admin'" @close="computerDockOpen=false" @changed="refresh()" @settings="shell?.openLocalVm()" @desktop="desktopAgent=$event;computerOpen=true" @workspace="desktopAgent=$event;twoDesktops=true" />
+    </div>
+    <ComputerPanel ref="desktopViewer" v-if="computerOpen&&desktopAgent" :agents="[desktopAgent]" auto-take @changed="load()" @close="computerOpen=false" />
+    <LocalVmWorkspace ref="vmWorkspace" v-if="twoDesktops&&desktopAgent" :agents="agents.filter(a=>a.execution==='computer'&&!a.archived&&!a.computerEnvironmentId)" :primary="desktopAgent.id" @close="twoDesktops=false" />
     <FloatingResourceSearch section="groups" label="搜索聊天" :items="searchItems" tabbed @select="select" />
     <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
     <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
     <RemoteAgentPicker v-if="remotePickerOpen && !auth.isBotOnly" @close="remotePickerOpen = false" @added="remoteAdded" />
-    <dialog v-if="dialog" ref="dialogElement" class="editor" @cancel.prevent="closeDialog">
+    <Teleport to="body"><dialog v-if="dialog" ref="dialogElement" class="editor" @cancel.prevent="closeDialog">
       <form @submit.prevent="save">
         <header>
           <h2>
@@ -613,7 +715,7 @@ onBeforeUnmount(() => {
           <button type="button" aria-label="关闭" @click="closeDialog">×</button>
         </header>
         <p v-if="error" class="error" role="alert">{{ error }}</p>
-        <TeamPresetPicker v-if="dialog === 'group'" :selected="selectedPresetId" :available="agents.filter(a => !a.archived).length" @select="choosePreset" />
+        <TeamPresetPicker v-if="dialog === 'group'" :selected="selectedPresetId" :available="agents.filter(a => !a.archived && !a.temporaryGoalId).length" @select="choosePreset" />
         <label>名称<input v-model="form.name" :readonly="isRemoteAgent" required maxlength="100" /></label>
         <p v-if="isRemoteAgent">引用的 Agent 配置由远端管理,请在远端修改名称、头像和角色规则。</p>
         <AgentIdentityPanel v-if="isAgentDialog && !isRemoteAgent" :key="`${dialog}:${editingId}`" :profile="avatarProfile" embedded :show-name="false" :show-default-model="false" :show-actions="false" @avatar-change="form.avatar = $event" />
@@ -648,13 +750,17 @@ onBeforeUnmount(() => {
             "
           />
         </label>
+        <label v-if="isAgentDialog && !isRemoteAgent" class="team-management-permission">
+          <span><input v-model="form.canManageTeam" type="checkbox" aria-label="允许组建团队" aria-describedby="team-management-help" />允许组建团队</span>
+          <small id="team-management-help">允许自主创建成员、组建团队并启动任务。仅使用当前账号获准的基础 Agent,新成员默认不获得此权限。发起者需使用已启用工具桥的同机 Hermes,或支持团队工具的 Runner。隔离发起者新建的成员也使用隔离电脑。</small>
+        </label>
         <button v-if="!isAgentDialog && !auth.isBotOnly" type="button" @click="remotePickerOpen = true">添加远程 Agent</button>
         <fieldset v-if="selectedPreset" class="preset-role-mapping">
           <legend>角色分配</legend>
           <label v-for="(role, index) in selectedPreset.roles" :key="role.name">
             {{ role.name }}{{ role.host ? ' · 管理员' : '' }}
             <select :value="form.memberIds[index]" :aria-label="`${role.name}对应的 Agent`" @change="assignPresetRole(index, ($event.target as HTMLSelectElement).value)">
-              <option v-for="a in agents.filter(a => !a.archived)" :key="a.id" :value="a.id">{{ a.name }}</option>
+              <option v-for="a in agents.filter(a => !a.archived && !a.temporaryGoalId)" :key="a.id" :value="a.id">{{ a.name }}</option>
             </select>
             <small>{{ role.description }}</small>
           </label>
@@ -664,7 +770,7 @@ onBeforeUnmount(() => {
           <legend>选择成员</legend>
           <label
             v-for="a in agents.filter((a) =>
-              !a.archived || (dialog === 'editGroup' && active?.memberIds.includes(a.id)),
+              !a.temporaryGoalId && (!a.archived || (dialog === 'editGroup' && active?.memberIds.includes(a.id))),
             )"
             :key="a.id"
             ><input
@@ -673,7 +779,7 @@ onBeforeUnmount(() => {
               :value="a.id"
               :disabled="busy || (dialog === 'editGroup' && (a.id === active?.administratorId || a.id === form.administratorId)) || (!form.memberIds.includes(a.id) && form.memberIds.length >= 8)"
             />{{ a.name }}</label
-          ><small v-if="dialog === 'group' && agents.filter((a) => !a.archived).length < 2"
+          ><small v-if="dialog === 'group' && agents.filter((a) => !a.archived && !a.temporaryGoalId).length < 2"
             >至少需要两个 Agent 才能创建群聊。</small
           >
           <small v-if="dialog === 'editGroup'">可增减成员,最多 8 位。当前管理员不能移除;如需移除,请先更换管理员并保存。</small>
@@ -724,10 +830,11 @@ onBeforeUnmount(() => {
           </button>
         </footer>
       </form>
-    </dialog>
+    </dialog></Teleport>
   </WorkspaceShell>
 </template>
 <style scoped>
+.conversation-with-computer{display:flex;height:100%;min-width:0;min-height:0}.conversation-with-computer>.workspace-chat{flex:1;min-width:0}
 .workspace-chat{display:flex;min-width:0;min-height:0;flex:1;flex-direction:column}.new-actions{display:grid;gap:2px}.new-actions button{display:flex;align-items:center;min-height:40px;padding:0 11px;border:0;border-radius:9px;background:transparent;color:var(--text-primary);text-align:left;font-size:12px;font-weight:610;cursor:pointer}.new-actions button:hover{background:var(--surface-hover)}.header-actions{display:flex;gap:4px;margin-left:auto}.error{display:flex;align-items:center;justify-content:space-between;margin:0 18px 9px;color:var(--danger);font-size:12px}.team-avatar-settings{display:grid;gap:12px}.team-avatar-options{display:flex;gap:10px}.team-avatar-options button{padding:5px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft);cursor:pointer}.team-avatar-options button[aria-pressed=true]{border-color:var(--accent)}.nodes-overlay{position:fixed;inset:0;z-index:1000;background:var(--scrim);display:grid;place-items:center}.nodes-overlay>div{padding:20px;max-height:85vh;overflow:auto;width:min(520px,90vw);background:var(--surface-raised);border:1px solid var(--line);border-radius:16px}
 .editor {
   border: 1px solid var(--line);
@@ -759,6 +866,19 @@ onBeforeUnmount(() => {
   margin: 15px 0;
   font-size: 13px;
 }
+.task-picker { max-width:180px;min-height:36px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);padding:4px 8px;font:inherit }
+.task-queue-status{margin:0 20px 8px;font-size:13px;color:var(--text-muted)}
+@media(max-width:720px){.task-picker{max-width:100px}}
+.team-management-permission > span {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-height: 44px;
+}
+.team-management-permission small {
+  color: var(--text-muted);
+  line-height: 1.6;
+}
 .editor input:not([type='checkbox']),
 .editor textarea,
 .editor select,

+ 303 - 0
src/runner/agent.ts

@@ -0,0 +1,303 @@
+import { randomUUID, createHash } from 'node:crypto'
+import { DatabaseSync } from 'node:sqlite'
+import { realpath,access } from 'node:fs/promises'
+import { resolve, sep, join } from 'node:path'
+import {LocalVmImages} from './computers/localVm.js'
+import {ComputerError} from './computers/container.js'
+import {z} from 'zod'
+import {ComputerRuntime,ComputerGateway,type ComputerTarget} from './worker/gateway.js'
+import { HttpError } from '../server/errors.js'
+import { UpstreamClient } from '../server/upstream.js'
+import { UpstreamServiceSession } from '../server/localAuth.js'
+import { WorkspaceGateway, type GatewayTarget, type GatewayFrame } from '../server/workspaceGateway.js'
+import { createWorkspaceToolLease, type WorkspaceToolLease } from '../server/workspaceToolLease.js'
+import {UNCONFIGURED_COMPUTER_IMAGE} from '../shared/runner.js'
+import type { RunnerCommand, RunnerConfiguration } from '../shared/runner.js'
+import { LoopbackTransport, isLocalAuthorizationTarget } from '../server/loopbackAuthorization.js'
+
+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 {cleanupOnly?:boolean;gateway:WorkspaceGateway|ComputerGateway;sessions:Map<string,string>;running:Set<string>;events:Promise<void>}
+interface Lease {connectionId:string;profile:string;lease:WorkspaceToolLease;controller:AbortController;session:{runtimeId:string;storedId:string}}
+
+export class RunnerAgent {
+  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 computers?:ComputerRuntime
+  private localVm?:LocalVmImages
+  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)')
+    if(config.computers){this.computers=new ComputerRuntime(this.db,config,home);this.localVm=new LocalVmImages(this.computers,this.db)}
+    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','x-runner-features':this.computers?'computer-worker-v1,artifact-chunks-v1,helper-retirement-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
+      ...(body===undefined?{}:{body:JSON.stringify(body)}),
+    })
+    if(!response.ok)throw new HttpError(response.status,`执行节点连接被拒绝(HTTP ${response.status})`,response.status===403?'runner_unauthorized':'runner_request_failed')
+    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)}}
+    await 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==='local-vm.manage'){
+      if(!this.localVm)throw new HttpError(409,'本地虚拟机尚未配置','computer_unavailable')
+      const input=z.object({owner:z.string().regex(/^[a-f0-9]{64}$/),op:z.enum(['status','prepare','policy','instance']),id:z.string().uuid().optional(),mode:z.enum(['shared','per-bot']).optional(),maxInstances:z.number().int().min(1).max(4).optional(),environmentId:z.string().uuid().optional(),action:z.enum(['stop','remove']).optional()}).strict().parse(p)
+      const check=async()=>{if(!this.connected||!this.active||(await this.api('local-vm-check',{id:input.id})).allowed!==true)throw new ComputerError('local_vm_authorization_revoked','本地虚拟机配置授权已失效')}
+      if(input.op==='status')return this.localVm.status(input.owner)
+      if(input.op==='instance'){
+        await check()
+        const spec=this.computers!.pool.definition(input.owner,input.environmentId!)
+        if(spec)await this.computers!.pool.desktop(spec,input.action!,()=>{if(!this.active||!this.connected)throw new Error('节点已断开')})
+        await check();return {ok:true}
+      }
+      if(input.op==='prepare')return this.localVm.prepare(input.id!,check)
+      await check();return this.localVm.policy(input.mode!,input.maxInstances!)
+    }
+    if(command.kind==='computer.control'){
+      if(!this.computers)throw new HttpError(409,'电脑服务未配置','computer_unavailable')
+      const input=z.object({target:z.object({environmentId:z.string().uuid(),agentId:z.string().uuid(),ownerKey:z.string().regex(/^[a-f0-9]{64}$/)}).strict(),profile:z.string(),op:z.enum(['status','detail','lifecycle','frame','take','input','giveback']),controlId:z.string().uuid().optional(),requestId:z.string().uuid().optional(),generation:z.number().int().optional(),frameId:z.string().uuid().optional(),action:z.unknown().optional(),notes:z.string().max(4000).optional()}).strict().parse(p)
+      this.requireProfile(input.profile)
+      const check=async()=>{if(!this.connected||!input.controlId||(await this.api('control-check',{controlId:input.controlId})).allowed!==true)throw new HttpError(403,'电脑控制授权已失效','computer_control_expired')}
+      const authorize=()=>{if(!this.connected||!this.active||Date.now()>command.expiresAt)throw new HttpError(403,'电脑请求已失效','computer_control_expired')}
+      await this.computers.ready
+      if(input.op==='detail'){
+        const vm=await this.localVm!.status(),spec=this.computers.pool.definition(input.target.ownerKey,input.target.environmentId)
+        const state=await this.computers.controls.status(input.target)
+        const actual=spec?await this.computers.provider.inspect(spec):undefined
+        let ready=false
+        if(actual?.running)try{await this.computers.provider.health(spec!,authorize);ready=true}catch{}
+        return {...state,container:actual?.running?'running':spec?'stopped':'missing',ready,inUse:['agent','pausing','human','resuming'].includes(state.mode),mode:vm.mode,maxInstances:vm.maxInstances,image:vm.image,controlMode:state.mode}
+      }
+      if(input.op==='lifecycle'){
+        const action=z.enum(['create','start','stop','recreate','remove']).parse(input.action)
+        await this.computers.desktop(input.target,input.profile,action,authorize)
+        return {ok:true}
+      }
+      if(input.op==='status')return this.computers.controls.status(input.target)
+      if(input.op==='frame')return this.computers.controls.frame(input.target,authorize)
+      if(input.op==='take')return this.computers.controls.take(input.target,input.profile,input.controlId!,check)
+      if(input.op==='input')return this.computers.controls.input(input.target,input.controlId!,input.requestId!,input.generation!,input.frameId!,input.action)
+      return this.computers.controls.giveBack(input.target,input.controlId!,input.notes??'')
+    }
+    if(command.kind==='computer.retire'){
+      const meta=p as unknown as ComputerTarget
+      if(!this.computers||![meta.environmentId,meta.agentId].every(value=>/^[0-9a-f-]{36}$/.test(value))||!/^[a-f0-9]{64}$/.test(meta.ownerKey))throw new HttpError(400,'电脑清理请求无效','computer_cleanup_invalid')
+      await this.computers.retire(meta);return {ok:true}
+    }
+    if(command.kind==='http') {
+      const path=String(p.path),search=new URLSearchParams(String(p.search??''))
+      if(path==='/api/computer/capabilities'||(p.computer&&path==='/api/plugins/yaoyao-bot-bridge/capabilities')){
+        this.requireProfile(search.get('profile')??'default')
+        if(p.method!=='GET'||!this.computers)throw new HttpError(409,'执行节点未配置隔离电脑','computer_unavailable')
+        await Promise.all([access(this.computers.config.python),access(this.computers.config.hermesSource),access(this.computers.script)])
+        return {status:200,headers:{},body:Buffer.from(JSON.stringify({version:1,ready:true,native_tools:true,in_process:true,network:this.config.computers?.network??'none'})).toString('base64')}
+      }
+      if(p.computer&&path==='/api/files/download')throw new HttpError(409,'隔离电脑产物需通过产物导出接口读取','computer_artifact_required')
+      if(p.computer&&/^\/api\/sessions\/[^/]+\/messages$/.test(path)){
+        if(p.method!=='GET'||!this.computers)throw new HttpError(403,'电脑历史不可用','computer_unavailable')
+        const profile=this.requireProfile(search.get('profile')??'default'),session=this.computers.session(decodeURIComponent(path.split('/')[3]!),p.computer as ComputerTarget,profile)
+        const offset=Math.max(0,Number(search.get('offset')??0)),history=session.history
+        return {status:200,headers:{},body:Buffer.from(JSON.stringify({messages:history.slice(Math.max(0,history.length-offset-500),history.length-offset)})).toString('base64')}
+      }
+      if(p.method!=='GET'||!(/^\/api\/(?:status|profiles|config)$/.test(path)||/^\/api\/sessions\/[^/]+\/messages$/.test(path)||path==='/api/plugins/yaoyao-bot-bridge/capabilities'||path==='/api/files/download'))throw new HttpError(403,'Runner 不支持此 HTTP 操作','runner_http_forbidden')
+      if(!['/api/status','/api/profiles'].includes(path))this.requireProfile(search.get('profile')??'default')
+      if(path==='/api/files/download') {
+        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('执行通道无效或已达上限')
+      let gateway:WorkspaceGateway|ComputerGateway
+      if(p.computer){
+        const meta=p.computer as ComputerTarget
+        if(!this.computers||![meta.environmentId,meta.agentId,String(p.workId)].every(value=>/^[0-9a-f-]{36}$/.test(value))||!/^[a-f0-9]{64}$/.test(meta.ownerKey))throw new HttpError(409,'电脑任务配置或授权无效','computer_unavailable')
+        gateway=new ComputerGateway(this.computers,meta,String(p.workId),async()=>{if(!this.active||!this.connected||(await this.api('check',{connectionId})).allowed!==true)throw new HttpError(403,'电脑任务授权已失效','computer_authorization_revoked')},body=>this.api('artifact',{connectionId,...body}))
+      }else gateway=new WorkspaceGateway(this.target)
+      const connection:Connection={cleanupOnly:p.cleanupOnly===true,gateway,sessions:new Map(),running:new Set(),events:Promise.resolve()}
+      this.connections.set(connectionId,connection)
+      gateway.onEvent=(frame:GatewayFrame)=>{
+        if(frame.session_id&&!connection.sessions.has(frame.session_id))return
+        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(connection.cleanupOnly&&!['session.resume','session.interrupt','session.close'].includes(method))throw new HttpError(403,'清理通道不允许执行新工作','runner_cleanup_forbidden')
+      if(method==='session.create'||method==='session.resume'){if(!connection.cleanupOnly)this.requireProfile(params.profile)}
+      else if(!connection.sessions.has(String(params.session_id)))throw new HttpError(403,'会话不属于这个通道','runner_session_forbidden')
+      if(method==='prompt.submit')connection.running.add(String(params.session_id))
+      const result=await connection.gateway.rpc(method,params)
+      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 virtual=source[1].gateway
+      if(virtual instanceof ComputerGateway){
+        virtual.installTeamLease(leaseId,p.catalog as any[],async(toolId,args,callId)=>(await this.api('tool',{leaseId,toolId,arguments:args,callId})).result)
+        const lease={bind:async()=>{},dispose:async()=>{virtual.removeTeamLease(leaseId)}}
+        this.leases.set(leaseId,{lease,controller,session,connectionId:source[0],profile:String(p.profile)});return {ok:true}
+      }
+      const lease=await createWorkspaceToolLease({target:this.target,profile:String(p.profile),workId:String(p.workId),session:()=>session,signal:controller.signal,
+        assertActive:()=>{if(!this.connected||controller.signal.aborted)throw new Error('工具授权已失效')},
+        catalog:()=>p.catalog as any[],call:async(toolId,args,callId)=>(await this.api('tool',{leaseId,toolId,arguments:args,callId:callId??randomUUID()})).result,
+        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 instanceof ComputerError?error.message:'执行节点操作失败',code:error instanceof HttpError||error instanceof ComputerError?error.code:'runner_error'}}))
+      .then(reply=>{this.db.prepare("UPDATE commands SET state='complete',result=? WHERE id=?").run(JSON.stringify(reply),command.id);return reply})
+      .finally(()=>this.inflight.delete(command.id))
+    this.inflight.set(command.id,pending);return pending
+  }
+  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 cleanupTimer=setInterval(()=>{void this.computers?.pool.expire().catch(()=>{})},5000);cleanupTimer.unref()
+    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{
+      clearInterval(cleanupTimer)
+      signal.removeEventListener('abort',abort)
+      try{
+        await this.disconnect().catch(()=>{})
+        await Promise.allSettled(deliveries.values());await Promise.allSettled(this.inflight.values())
+        await this.localVm?.close();await this.computers?.controls.close();await this.computers?.pool.close()
+      }finally{this.target.client.close();this.controlTransport.close();this.db.close();this.status('执行节点已停止')}
+    }
+  }
+}

+ 250 - 0
src/runner/computers/container.ts

@@ -0,0 +1,250 @@
+import { createHash, randomBytes } from 'node:crypto'
+import { spawn } from 'node:child_process'
+import { mkdir, realpath, chmod, lstat, rm } from 'node:fs/promises'
+import { join, resolve, posix } from 'node:path'
+import { z } from 'zod'
+import {recoverWorkspace} from './workspaceRecovery.js'
+
+export const COMPUTER_WORKSPACE = '/home/cua/workspace'
+export const CUA_DRIVER = '/usr/local/libexec/openmausbot/cua-driver'
+export const CUA_SOCKET = '/run/user/1000/openmausbot-cua.sock'
+const managedLabel='cn.samien.yaoyao.computer', runnerLabel='cn.samien.yaoyao.runner', specLabel='cn.samien.yaoyao.computer-spec', ownerLabel='cn.samien.yaoyao.computer-owner'
+const mib=1024*1024
+export interface CommandResult {stdout:string;stderr:string}
+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({
+  network:z.enum(['none','public-proxy']).default('none'),
+  id:z.string().uuid(),ownerKey:z.string().min(1).max(256),
+  imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),
+  cpus:z.number().int().min(1).max(8).default(2),memoryMiB:z.number().int().min(1024).max(16384).default(4096),
+  cwd:z.string().max(4096).refine(value=>!/[\u0000-\u001f,]/.test(value)&&posix.isAbsolute(value)&&posix.normalize(value)===value&&value!=='/'&&!/^\/(?:proc|sys|dev|run|tmp|etc|usr|bin|sbin)(?:\/|$)/.test(value)&&value!=='/home/cua','电脑工作目录不受支持').default(COMPUTER_WORKSPACE),
+  pids:z.number().int().min(64).max(1024).default(512),
+}).strict()
+export type ComputerSpecification=z.input<typeof schema>
+type Spec=z.output<typeof schema>
+export interface ComputerState {id:string;containerId:string;running:boolean;workspace:string;isolation:'container'|'vm'}
+export interface ComputerProvider {
+  validateSpecification(spec:ComputerSpecification):ComputerSpecification
+  readonly capabilities:{isolation:'container'|'vm';shell:boolean;desktop:boolean;persistentWorkspace:boolean;sharedDesktop:boolean;snapshots:boolean;network:'none'|'public-proxy'}
+  inspect(spec:ComputerSpecification):Promise<ComputerState|undefined>
+  ensure(spec:ComputerSpecification,authorize:()=>void):Promise<ComputerState>
+  stop(spec:ComputerSpecification):Promise<void>
+  remove(spec:ComputerSpecification):Promise<void>
+  execute(spec:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer}):Promise<CommandResult>
+  health(spec:ComputerSpecification,authorize:()=>void):Promise<unknown>
+}
+export class ComputerError extends Error { constructor(readonly code:string,message:string){super(message)} }
+const digest=(value:unknown)=>createHash('sha256').update(JSON.stringify(value)).digest('hex')
+const capabilities=(values:unknown)=>Array.isArray(values)?values.map(value=>String(value).toUpperCase().replace(/^CAP_/, '')).sort():[]
+
+/** A private container per environment. No tools, filesystem operations or
+ * process execution fall back to the host. Remote hosts run their own Runner. */
+export class ContainerComputerProvider implements ComputerProvider {
+  private queue=new Map<string,Promise<unknown>>()
+  readonly capabilities={isolation:'container',shell:true,desktop:true,persistentWorkspace:true,sharedDesktop:false,snapshots:false,network:'none'} as const
+  constructor(readonly runtime:'docker'|'podman',readonly runnerId:string,readonly home:string,readonly run:ContainerCommand=command,readonly environment:NodeJS.ProcessEnv=process.env) {
+    z.string().uuid().parse(runnerId)
+    if(!['docker','podman'].includes(runtime))throw new ComputerError('runtime_unsupported','电脑运行时不受支持')
+  }
+  async verifyRuntime() {
+    if(this.runtime==='docker') {
+      const endpoint=this.environment.DOCKER_HOST||JSON.parse((await this.run('docker',['context','inspect','--format','{{json .Endpoints.docker.Host}}'])).stdout)
+      if(typeof endpoint!=='string'||!(/^(?:unix|npipe):\/\//.test(endpoint)))throw new ComputerError('computer_remote_engine','远程 Docker 引擎请在目的电脑安装 Runner')
+    }else{
+      const connections=JSON.parse((await this.run('podman',['system','connection','list','--format','json'])).stdout)
+      const endpoint=this.environment.CONTAINER_HOST||connections.find((entry:any)=>entry.Default)?.URI
+      if(endpoint){const url=new URL(endpoint);if(url.protocol!=='unix:'&&!['127.0.0.1','localhost','[::1]'].includes(url.hostname))throw new ComputerError('computer_remote_engine','远程 Podman 引擎请在目的电脑安装 Runner')}
+    }
+  }
+  validateSpecification(value:ComputerSpecification):Spec {
+    const parsed=schema.safeParse(value)
+    if(!parsed.success)throw new ComputerError('computer_spec_invalid','电脑环境配置无效')
+    return parsed.data
+  }
+  private name(spec:Spec){return `yaoyao-computer-${digest([this.runnerId,spec.id]).slice(0,24)}`}
+  private labels(spec:Spec){return {[managedLabel]:spec.id,[runnerLabel]:this.runnerId,[ownerLabel]:digest(spec.ownerKey),[specLabel]:digest(spec)}}
+  private serial<T>(spec:Spec,action:()=>Promise<T>):Promise<T> {
+    const result=(this.queue.get(spec.id)??Promise.resolve()).catch(()=>{}).then(action)
+    this.queue.set(spec.id,result)
+    void result.finally(()=>{if(this.queue.get(spec.id)===result)this.queue.delete(spec.id)}).catch(()=>{})
+    return result
+  }
+  private async workspace(spec:Spec) {
+    const base=resolve(this.home,'computer-workspaces')
+    await mkdir(base,{recursive:true,mode:0o700})
+    const canonical=await realpath(base),path=join(canonical,spec.id)
+    await recoverWorkspace(canonical,spec.id)
+    if(/[,\n\r\0]/.test(path))throw new ComputerError('computer_path_invalid','电脑数据目录包含不支持的字符')
+    await mkdir(path,{recursive:true,mode:0o700})
+    if(await realpath(path)!==path)throw new ComputerError('computer_path_unsafe','电脑工作区不能指向其他目录')
+    if(process.platform!=='win32')await chmod(path,0o700)
+    return path
+  }
+  private async inspectRaw(name:string):Promise<any|undefined> {
+    try {const result=await this.run(this.runtime,['inspect',name]);return JSON.parse(result.stdout)[0]}
+    catch(error){
+      if(/no such (?:object|container)|no container with name or ID/i.test(String((error as any).stderr??'')))return undefined
+      throw error
+    }
+  }
+  private assertOwned(spec:Spec,detail:any) {
+    if(!detail||!/^[a-f0-9]{64}$/.test(String(detail.Id))||Object.entries(this.labels(spec)).filter(([key])=>key!==specLabel).some(([key,value])=>detail.Config?.Labels?.[key]!==value))
+      throw new ComputerError('computer_owner_mismatch','电脑实例不属于当前执行节点或配置已变化')
+  }
+  private assertConfiguration(spec:Spec,detail:any,workspace:string) {
+    this.assertOwned(spec,detail)
+    const host=detail.HostConfig??{},expectedCaps=this.runtime==='podman'?['SETGID','SETUID','SYS_CHROOT']:['SETGID','SETUID']
+    const nanoCPUs=host.NanoCpus??(host.CpuPeriod>0?host.CpuQuota/host.CpuPeriod*1e9:0)
+    const noPrivilege=(host.SecurityOpt??[]).some((value:string)=>/^no-new-privileges(?::true)?$/.test(value))
+    const ports=Object.entries(host.PortBindings??{}),mounts=detail.Mounts??[]
+    const destinations=spec.cwd===COMPUTER_WORKSPACE?[COMPUTER_WORKSPACE]:[COMPUTER_WORKSPACE,spec.cwd]
+    if(detail.Config?.Labels?.[specLabel]!==digest(spec)||detail.Image!==spec.imageId||host.Privileged!==false||host.Memory!==spec.memoryMiB*mib||host.MemorySwap!==spec.memoryMiB*mib
+      ||nanoCPUs!==spec.cpus*1e9||host.PidsLimit!==spec.pids||host.IpcMode!=='private'||host.CgroupnsMode!=='private'
+      ||host.NetworkMode!=='none'||host.PidMode==='host'||host.UsernsMode==='host'||host.UTSMode==='host'
+      ||!noPrivilege||(host.SecurityOpt??[]).some((value:string)=>/unconfined/.test(value))
+      ||JSON.stringify(capabilities(host.CapAdd))!==JSON.stringify(expectedCaps)||!capabilities(host.CapDrop).includes('ALL')
+      ||(host.Devices??[]).length||(host.DeviceRequests??[]).length||host.RestartPolicy?.Name!=='no'
+      ||mounts.length!==destinations.length||mounts.some((mount:any)=>mount.Type!=='bind'||mount.Source!==workspace||!destinations.includes(mount.Destination)||mount.RW!==true)||new Set(mounts.map((mount:any)=>mount.Destination)).size!==destinations.length
+      ||ports.length!==0)
+      throw new ComputerError('computer_configuration_unsafe','电脑实例的隔离、资源或数据目录不符合要求,请先停止并核对')
+  }
+  private state(spec:Spec,detail:any,workspace:string):ComputerState {
+    this.assertConfiguration(spec,detail,workspace)
+    if(Object.values(detail.NetworkSettings?.Ports??{}).some(bindings=>Array.isArray(bindings)&&bindings.length))throw new ComputerError('computer_viewer_unsafe','电脑环境不能发布网络端口')
+    return {id:spec.id,containerId:detail.Id,running:detail.State?.Running===true,workspace,isolation:'container'}
+  }
+  async inspect(value:ComputerSpecification):Promise<ComputerState|undefined> {
+    await this.verifyRuntime()
+    const spec=this.validateSpecification(value),workspace=await this.workspace(spec),detail=await this.inspectRaw(this.name(spec))
+    return detail?this.state(spec,detail,workspace):undefined
+  }
+  private args(spec:Spec,workspace:string) {
+    return ['create','--name',this.name(spec),'--hostname',this.name(spec),// The XFCE/VNC startup resolves its own hostname even without a network.
+      '--add-host',`${this.name(spec)}:127.0.0.1`,
+      ...Object.entries(this.labels(spec)).flatMap(([key,value])=>['--label',`${key}=${value}`]),
+      '--restart','no','--network','none','--ipc','private','--cgroupns','private',
+      '--memory',`${spec.memoryMiB}m`,'--memory-swap',`${spec.memoryMiB}m`,'--cpus',String(spec.cpus),'--pids-limit',String(spec.pids),
+      '--shm-size','512m','--security-opt','no-new-privileges:true','--cap-drop','ALL','--cap-add','SETUID','--cap-add','SETGID',
+      ...(this.runtime==='podman'?['--userns','keep-id:uid=1000,gid=1000','--user','root','--cap-add','SYS_CHROOT']:[]),
+      ...[...new Set([COMPUTER_WORKSPACE,spec.cwd])].flatMap(destination=>['--mount',`type=bind,source=${workspace},target=${destination}${this.runtime==='podman'?',relabel=private':''}`]),
+      ...(spec.network==='public-proxy'?['--env','HTTP_PROXY=http://127.0.0.1:3128','--env','HTTPS_PROXY=http://127.0.0.1:3128','--env','http_proxy=http://127.0.0.1:3128','--env','https_proxy=http://127.0.0.1:3128','--env','NO_PROXY=localhost,127.0.0.1,::1','--env','no_proxy=localhost,127.0.0.1,::1']:[]),
+      '--env',`VNC_PW=${randomBytes(24).toString('base64url')}`,spec.imageId]
+  }
+  async ensure(value:ComputerSpecification,authorize:()=>void):Promise<ComputerState> {
+    const spec=this.validateSpecification(value)
+    return this.serial(spec,async()=>{
+      authorize();await this.verifyRuntime();authorize()
+      const workspace=await this.workspace(spec)
+      let detail=await this.inspectRaw(this.name(spec))
+      if(!detail) {
+        const image=JSON.parse((await this.run(this.runtime,['image','inspect',spec.imageId])).stdout)[0]
+        if(image?.Id!==spec.imageId||image.Config?.Labels?.['com.openmausbot.cua-driver']!=='0.20.0'||image.Config?.Labels?.['com.openmausbot.image-layer']!=='5')
+          throw new ComputerError('computer_image_incompatible','电脑镜像缺少已验证的驱动,请先安装兼容镜像')
+        authorize()
+        const created=await this.run(this.runtime,this.args(spec,workspace))
+        const id=created.stdout.trim()
+        if(!/^[a-f0-9]{64}$/.test(id))throw new ComputerError('computer_creation_uncertain','电脑创建结果不确定,请核对实例')
+        detail=await this.inspectRaw(id)
+      }
+      let state=this.state(spec,detail,workspace)
+      authorize()
+      if(!state.running)await this.run(this.runtime,['start','--',state.containerId])
+      try {
+        authorize();detail=await this.inspectRaw(state.containerId);state=this.state(spec,detail,workspace);authorize()
+        if(!state.running)throw new ComputerError('computer_start_failed','电脑环境未能启动')
+        return state
+      }catch(error){await this.stopOwned(spec,state.containerId).catch(()=>{});throw error}
+    })
+  }
+  private async stopOwned(spec:Spec,id:string) {
+    const detail=await this.inspectRaw(id)
+    if(!detail)return
+    this.assertOwned(spec,detail)
+    if(detail.State?.Running)await this.run(this.runtime,['stop','--time','10','--',id],{timeout:15000})
+    const stopped=await this.inspectRaw(id)
+    if(stopped?.State?.Running)throw new ComputerError('computer_stop_uncertain','尚未确认电脑环境已停止')
+  }
+  async stop(value:ComputerSpecification):Promise<void> {
+    const spec=this.validateSpecification(value)
+    return this.serial(spec,async()=>{await this.verifyRuntime();const detail=await this.inspectRaw(this.name(spec));if(detail){this.assertOwned(spec,detail);await this.stopOwned(spec,detail.Id)}})
+  }
+  async remove(value:ComputerSpecification):Promise<void> {
+    const spec=this.validateSpecification(value)
+    return this.serial(spec,async()=>{
+      await this.verifyRuntime()
+      const detail=await this.inspectRaw(this.name(spec));if(!detail)return
+      this.assertOwned(spec,detail);await this.stopOwned(spec,detail.Id)
+      await this.run(this.runtime,['rm','--',detail.Id])
+      // Persistent workspace deletion is a separate, explicit operation.
+    })
+  }
+  async execute(value:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer}):Promise<CommandResult> {
+    const spec=this.validateSpecification(value)
+    if(!argv.length||argv.length>64||argv.some(arg=>typeof arg!=='string'||arg.includes('\0'))||argv.join('').length>65536)throw new ComputerError('computer_command_invalid','电脑命令无效或过长')
+    return this.serial(spec,async()=>{
+      options.authorize();if(options.signal?.aborted)throw new ComputerError('computer_cancelled','操作已停止')
+      const state=await this.inspect(spec)
+      if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
+      options.authorize()
+      try {
+        const result=await this.run(this.runtime,['exec',...(options.input?['-i']:[]),'--user','1000:1000','--workdir',spec.cwd,'--',state.containerId,...argv],{timeout:Math.min(60000,Math.max(1000,options.timeout??30000)),signal:options.signal,input:options.input})
+        options.authorize();return result
+      }catch(error){
+        // Killing a docker exec client alone cannot prove its guest process stopped.
+        // A cancelled or unacknowledged operation closes this private environment.
+        if(options.signal?.aborted||(error as any).killed||typeof (error as any).code!=='number')await this.stopOwned(spec,state.containerId)
+        throw error
+      }
+    })
+  }
+  async capture(value:ComputerSpecification,authorize:()=>void):Promise<{data:string;width:number;height:number;capturedAt:number}> {
+    const spec=this.validateSpecification(value),state=await this.inspect(spec)
+    authorize();if(!state?.running)throw new ComputerError('computer_not_running','电脑尚未运行')
+    const file=`/tmp/yaoyao-view-${randomBytes(12).toString('hex')}.png`
+    const exec=(args:string[])=>this.run(this.runtime,['exec','--user','1000:1000','--',state.containerId,...args],{timeout:10000})
+    try{
+      await exec([CUA_DRIVER,'call','get_desktop_state','{}','--socket',CUA_SOCKET,'--screenshot-out-file',file]);authorize()
+      const data=(await exec(['base64','-w0',file])).stdout.trim(),bytes=Buffer.from(data,'base64')
+      if(bytes.length<24||bytes.subarray(1,4).toString()!=='PNG')throw new ComputerError('computer_frame_invalid','电脑画面无效')
+      authorize();return {data,width:bytes.readUInt32BE(16),height:bytes.readUInt32BE(20),capturedAt:Date.now()}
+    }finally{await exec(['rm','-f','--',file]).catch(()=>{})}
+  }
+  async deleteWorkspace(value:ComputerSpecification):Promise<void>{
+    const spec=this.validateSpecification(value)
+    if(await this.inspectRaw(this.name(spec)))throw new ComputerError('computer_busy','删除工作区前必须移除电脑实例')
+    const base=await realpath(resolve(this.home,'computer-workspaces')).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
+    if(!base)return
+    const path=join(base,spec.id),info=await lstat(path).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
+    if(!info)return
+    if(!info.isDirectory()||info.isSymbolicLink()||await realpath(path)!==path)throw new ComputerError('computer_path_unsafe','拒绝删除非预期的电脑工作区')
+    await rm(path,{recursive:true})
+  }
+  async openPipe(value:ComputerSpecification,argv:string[],authorize:()=>void){
+    const spec=this.validateSpecification(value)
+    authorize();const state=await this.inspect(spec);authorize()
+    if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
+    return spawn(this.runtime,['exec','-i','--user','1000:1000','--workdir',spec.cwd,'--',state.containerId,...argv],{stdio:'pipe'})
+  }
+  async configureNetwork(value:ComputerSpecification,authorize:()=>void){
+    const spec=this.validateSpecification(value)
+    if(spec.network!=='public-proxy')return
+    authorize();const state=await this.inspect(spec);authorize()
+    if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
+    const script='import pathlib,json; chrome={"ProxyMode":"fixed_servers","ProxyServer":"http://127.0.0.1:3128","ProxyBypassList":"localhost;127.0.0.1;[::1]","QuicAllowed":False}; firefox={"policies":{"Proxy":{"Mode":"manual","HTTPProxy":"127.0.0.1:3128","SSLProxy":"127.0.0.1:3128","Passthrough":"localhost,127.0.0.1,::1","Locked":True},"DNSOverHTTPS":{"Enabled":False,"Locked":True}}}; paths=[("/etc/chromium/policies/managed/yaoyao.json",chrome),("/etc/opt/chrome/policies/managed/yaoyao.json",chrome),("/usr/lib/firefox/distribution/policies.json",firefox),("/usr/lib/firefox-esr/distribution/policies.json",firefox),("/opt/firefox/distribution/policies.json",firefox)]; [(pathlib.Path(p).parent.mkdir(parents=True,exist_ok=True),pathlib.Path(p).write_text(json.dumps(v))) for p,v in paths]'
+    await this.run(this.runtime,['exec','--user','0','--',state.containerId,'python3','-c',script])
+    authorize()
+  }
+  async health(value:ComputerSpecification,authorize:()=>void):Promise<unknown> {
+    return JSON.parse((await this.execute(value,[CUA_DRIVER,'call','health_report','{}','--socket',CUA_SOCKET],{authorize})).stdout)
+  }
+}

+ 22 - 0
src/runner/computers/imageCli.ts

@@ -0,0 +1,22 @@
+import {parseArgs} from 'node:util'
+import {fileURLToPath} from 'node:url'
+import {ComputerImages,writeManifest} from './images.js'
+import {ContainerComputerProvider} from './container.js'
+import {randomUUID} from 'node:crypto'
+import {tmpdir} from 'node:os'
+try{
+  const {values,positionals}=parseArgs({allowPositionals:true,options:{runtime:{type:'string',default:'docker'},image:{type:'string'},archive:{type:'string'},output:{type:'string'},recipe:{type:'string'}}})
+  if(!['docker','podman'].includes(values.runtime!))throw new Error('运行时必须为 docker 或 podman')
+  const runtime=values.runtime as 'docker'|'podman',images=new ComputerImages(runtime)
+  await new ContainerComputerProvider(runtime,randomUUID(),tmpdir()).verifyRuntime()
+  let result
+  switch(positionals[0]){
+    case 'prepare': result=await images.prepare(values.recipe??fileURLToPath(new URL('./computer-image',import.meta.url)));break
+    case 'inspect':if(!values.image)throw new Error('需要 --image sha256:完整镜像ID');result=await images.inspect(values.image);break
+    case 'export':if(!values.image||!values.archive)throw new Error('需要 --image 和 --archive');result=await images.export(values.image,values.archive);break
+    case 'import':if(!values.archive)throw new Error('需要 --archive');result=await images.import(values.archive);break
+    default:throw new Error('用法:runner-image prepare|inspect|export|import [--runtime docker|podman] [--image ID] [--archive 文件] [--output 清单.json]')
+  }
+  if(values.output)await writeManifest(values.output,result)
+  process.stdout.write(JSON.stringify(result,null,2)+'\n')
+}catch(error){process.stderr.write(`${error instanceof Error?error.message:'镜像操作失败'}\n`);process.exitCode=1}

+ 74 - 0
src/runner/computers/images.ts

@@ -0,0 +1,74 @@
+import {createHash,randomUUID} from 'node:crypto'
+import {createReadStream} from 'node:fs'
+import {mkdir,mkdtemp,readFile,writeFile,rename,rm,stat} from 'node:fs/promises'
+import {join,dirname,resolve} from 'node:path'
+import {tmpdir} from 'node:os'
+import {spawn} from 'node:child_process'
+import {z} from 'zod'
+import {ContainerComputerProvider} from './container.js'
+
+const imageId=z.string().regex(/^sha256:[a-f0-9]{64}$/)
+export const imageManifest=z.object({protocol:z.literal(1),imageId,architecture:z.enum(['amd64','arm64']),driver:z.literal('0.20.0'),layer:z.literal('5'),createdAt:z.number().int().nonnegative(),archiveSha256:z.string().regex(/^[a-f0-9]{64}$/).optional()}).strict()
+export type ComputerImageManifest=z.infer<typeof imageManifest>
+export async function fileSHA256(path:string){const hash=createHash('sha256');for await(const bytes of createReadStream(path))hash.update(bytes);return hash.digest('hex')}
+export async function writeManifest(path:string,manifest:ComputerImageManifest){
+  const validated=imageManifest.parse(manifest),temporary=path+'.'+randomUUID()
+  await mkdir(dirname(path),{recursive:true,mode:0o700})
+  try{await writeFile(temporary,JSON.stringify(validated,null,2)+'\n',{flag:'wx',mode:0o600});await rename(temporary,path)}finally{await rm(temporary,{force:true})}
+}
+export type ImageCommand=(runtime:'docker'|'podman',args:string[])=>Promise<string>
+const command:ImageCommand=(runtime,args)=>new Promise((resolveResult,reject)=>{
+  const child=spawn(runtime,args,{stdio:['ignore','pipe','pipe']}),output:Buffer[]=[];let bytes=0,error:Error|undefined
+  const timer=setTimeout(()=>{error=new Error('镜像操作超时,请核对运行时后重试');child.kill()},15*60*1000)
+  child.stdout.on('data',chunk=>{bytes+=chunk.length;if(bytes<=4*1024*1024)output.push(chunk);else{error=new Error('镜像输出过大');child.kill()}})
+  child.stderr.on('data',chunk=>process.stderr.write(chunk))
+  child.once('error',cause=>{error=cause})
+  child.once('close',code=>{clearTimeout(timer);if(error||code!==0)reject(error??new Error(`镜像操作失败(${code})`));else resolveResult(Buffer.concat(output).toString())})
+})
+/** Image operations never rewrite a running environment or a Runner configuration. */
+export class ComputerImages {
+  constructor(readonly runtime:'docker'|'podman',readonly run:ImageCommand=command){}
+  async inspect(id:string):Promise<ComputerImageManifest>{
+    imageId.parse(id)
+    const image=JSON.parse(await this.run(this.runtime,['image','inspect',id]))[0]
+    if(image?.Id!==id||image.Os!=='linux'||image.Config?.Labels?.['com.openmausbot.cua-driver']!=='0.20.0'||image.Config?.Labels?.['com.openmausbot.image-layer']!=='5')throw new Error('镜像身份或电脑驱动不兼容')
+    return imageManifest.parse({protocol:1,imageId:id,architecture:image.Architecture,driver:'0.20.0',layer:'5',createdAt:Date.now()})
+  }
+  async prepare(recipe:string):Promise<ComputerImageManifest>{
+    const tag=`localhost/yaoyao/computer:prepare-${randomUUID()}`
+    await this.run(this.runtime,['build','--pull=false','--tag',tag,resolve(recipe)])
+    const id=JSON.parse(await this.run(this.runtime,['image','inspect',tag]))[0]?.Id
+    const manifest=await this.inspect(id)
+    await this.verify(manifest)
+    return manifest
+  }
+  async verify(manifest:ComputerImageManifest){
+    const home=await mkdtemp(join(tmpdir(),'yaoyao-image-check-')),provider=new ContainerComputerProvider(this.runtime,randomUUID(),home)
+    const spec={id:randomUUID(),ownerKey:'image-verification',imageId:manifest.imageId}
+    let stopped=false
+    try{
+      await provider.ensure(spec,()=>{})
+      const deadline=Date.now()+60000
+      while(true){
+        try{await provider.health(spec,()=>{});await provider.capture(spec,()=>{});break}
+        catch(error){if(Date.now()>=deadline)throw new Error('镜像已构建,但电脑驱动未在 60 秒内就绪:'+String((error as {stderr?:string}).stderr??error).slice(0,2000));await new Promise(resolve=>setTimeout(resolve,500))}
+      }
+    }
+    finally{try{await provider.remove(spec);stopped=true}finally{if(stopped)await rm(home,{recursive:true,force:true})}}
+  }
+  async export(id:string,archive:string){
+    const manifest=await this.inspect(id),temporary=archive+'.'+randomUUID()
+    // Exclusive reservation avoids overwriting a user archive on a repeated command.
+    await writeFile(archive,'',{flag:'wx',mode:0o600})
+    try{await this.run(this.runtime,['save','--output',temporary,id]);await rename(temporary,archive);manifest.archiveSha256=await fileSHA256(archive);await writeManifest(archive+'.json',manifest);return manifest}
+    catch(error){await rm(archive,{force:true});throw error}finally{await rm(temporary,{force:true})}
+  }
+  async import(archive:string){
+    const manifest=imageManifest.parse(JSON.parse(await readFile(archive+'.json','utf8')))
+    if(!manifest.archiveSha256||(await stat(archive)).size>20*1024**3||await fileSHA256(archive)!==manifest.archiveSha256)throw new Error('镜像归档校验失败,未导入')
+    await this.run(this.runtime,['load','--input',archive])
+    const actual=await this.inspect(manifest.imageId)
+    if(actual.architecture!==manifest.architecture)throw new Error('镜像架构与清单不一致')
+    await this.verify(actual);return actual
+  }
+}

+ 118 - 0
src/runner/computers/localVm.ts

@@ -0,0 +1,118 @@
+import { existsSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import type { DatabaseSync } from 'node:sqlite'
+import { ComputerImages } from './images.js'
+import { ComputerError } from './container.js'
+import { UNCONFIGURED_COMPUTER_IMAGE } from '../../shared/runner.js'
+import type { LocalVmMode, LocalVmStatus } from '../../shared/localVm.js'
+import type { ComputerRuntime } from '../worker/gateway.js'
+
+type State = { imageId: string; mode: LocalVmMode; maxInstances: number; job?: LocalVmStatus['job'] }
+
+/** One managed Local VM image. It is prepared in Settings, never selected from an image catalog. */
+export class LocalVmImages {
+  readonly images: ComputerImages
+  readonly ready: Promise<void>
+  private state: State
+  private pending?: Promise<void>
+  private closed = false
+  private releaseMaintenance?: () => void
+  constructor(readonly runtime: ComputerRuntime, readonly db: DatabaseSync) {
+    this.images = new ComputerImages(runtime.config.runtime)
+    db.exec('CREATE TABLE IF NOT EXISTS local_vm_runtime(id INTEGER PRIMARY KEY CHECK(id=1),value TEXT NOT NULL)')
+    const row = db.prepare('SELECT value FROM local_vm_runtime WHERE id=1').get() as {value:string}|undefined
+    this.state = row ? JSON.parse(row.value) : {imageId:runtime.config.imageId,mode:'per-bot',maxInstances:runtime.config.maxConcurrent ?? 2}
+    // Preserve an already selected image when migrating; old archives and workspaces are untouched.
+    if (!row && db.prepare("SELECT name FROM sqlite_master WHERE name='managed_image_selection'").get()) {
+      const old = db.prepare('SELECT value FROM managed_image_selection WHERE id=1').get() as {value:string}|undefined
+      if (old) this.state.imageId = JSON.parse(old.value).active ?? this.state.imageId
+    }
+    runtime.config.imageId = this.state.imageId
+    runtime.pool.limits.concurrent = this.state.maxInstances
+    runtime.retainDesktops = true
+    this.releaseMaintenance = runtime.pool.holdMaintenance()
+    this.ready = runtime.ready.then(async () => {
+      if (this.state.job?.state === 'running') {
+        try { await this.cleanup(this.state.job.id) }
+        catch {this.state.job={...this.state.job,state:'failed',message:'上次准备的测试虚拟机停止状态待核对,请启动容器运行环境后重新检查'};this.save();return}
+        this.state.job={...this.state.job,state:'failed',message:'上次准备已中断,可重新准备本地虚拟机'};this.save()
+      }
+      this.releaseMaintenance?.(); this.releaseMaintenance = undefined
+    })
+    void this.ready.catch(() => {})
+  }
+  private save() { this.db.prepare('INSERT OR REPLACE INTO local_vm_runtime VALUES(1,?)').run(JSON.stringify(this.state)) }
+  private spec(id:string,imageId=this.runtime.config.imageId) { return {id,ownerKey:'local-vm-prepare',imageId} }
+  private async cleanup(id:string) {
+    const spec = this.spec(id)
+    await this.runtime.provider.remove(spec)
+    await this.runtime.provider.deleteWorkspace(spec)
+  }
+  async status(owner?:string): Promise<LocalVmStatus> {
+    await this.ready
+    if(this.releaseMaintenance&&!this.pending&&this.state.job?.state==='failed') {
+      try{await this.cleanup(this.state.job.id);this.releaseMaintenance();this.releaseMaintenance=undefined}catch{}
+    }
+    let daemonUp=false,image=false,problem:string|undefined
+    try { await this.runtime.provider.verifyRuntime();daemonUp=true }
+    catch { problem='请先安装并启动 Docker 或 Podman' }
+    if(daemonUp && this.state.imageId!==UNCONFIGURED_COMPUTER_IMAGE) {
+      try { await this.images.inspect(this.state.imageId);image=true } catch { problem='本地虚拟机镜像尚未就绪,请重新准备' }
+    }
+    return {configured:true,runtime:this.runtime.config.runtime,daemonUp,image,imageId:image?this.state.imageId:undefined,
+      mode:this.state.mode,maxInstances:this.state.maxInstances,busy:!!this.pending||!!this.releaseMaintenance,problem,job:this.state.job,
+      instances:owner?this.runtime.pool.status(owner).map(row=>({id:row.environmentId,status:row.status})):[]}
+  }
+  async policy(mode:LocalVmMode,maxInstances:number) {
+    await this.ready
+    // Sharing changes future Agent bindings; an idle desktop can keep running.
+    // Image preparation still requires every desktop to be stopped.
+    const release=this.runtime.pool.beginMaintenance({allowIdle:true})
+    try { this.state.mode=mode;this.state.maxInstances=maxInstances;this.runtime.pool.limits.concurrent=maxInstances;this.save() }
+    finally { release() }
+    return this.status()
+  }
+  async prepare(id:string,check:()=>Promise<void>) {
+    await this.ready;await check()
+    if(this.state.job?.id===id)return this.status()
+    if(this.pending||this.closed)throw new ComputerError('local_vm_busy','本地虚拟机正在准备,请稍后查看')
+    this.releaseMaintenance=this.runtime.pool.beginMaintenance()
+    this.state.job={id,state:'running',message:'正在准备本地虚拟机'};this.save()
+    const progress=(message:string)=>{this.state.job!.message=message;this.save()}
+    this.pending=(async()=>{
+      try {
+        await this.runtime.provider.verifyRuntime();await check()
+        const verify=async(manifest:{imageId:string})=>{
+          progress('正在检查虚拟机桌面与电脑驱动')
+          const spec=this.spec(id,manifest.imageId)
+          try {
+            await this.runtime.provider.ensure(spec,()=>{if(this.closed)throw new Error('服务已停止')})
+            const deadline=Date.now()+60000
+            for(;;) { await check();try{await this.runtime.provider.health(spec,()=>{});await this.runtime.provider.capture(spec,()=>{});break}catch(error){if(Date.now()>deadline)throw error;await new Promise(r=>setTimeout(r,1000))} }
+          } finally { await this.runtime.provider.remove(spec);await this.runtime.provider.deleteWorkspace(spec) }
+        }
+        this.images.verify=verify
+        const ids=(await this.images.run(this.images.runtime,['image','ls','--no-trunc','--quiet','--filter','label=cn.samien.yaoyao.computer-image=1'])).trim().split(/\s+/).filter(Boolean)
+        let image
+        for(const value of [this.state.imageId,...ids].filter(id=>id!==UNCONFIGURED_COMPUTER_IMAGE)) { try{image=await this.images.inspect(value);break}catch{} }
+        if(image)await verify(image)
+        else {
+          progress('正在准备托管桌面镜像,首次下载可能需要数分钟')
+          const recipe=[join(dirname(this.runtime.script),'computer-image'),join(dirname(this.runtime.script),'../computers/computer-image'),resolve('deploy/computer')].find(p=>existsSync(join(p,'Dockerfile')))
+          if(!recipe)throw new Error('缺少本地虚拟机资源,请重新安装 App')
+          image=await this.images.prepare(recipe)
+        }
+        await check();this.state.imageId=image.imageId;this.runtime.config.imageId=image.imageId
+        this.state.job={id,state:'complete',message:'本地虚拟机已就绪,可在 Agent 的电脑面板中创建桌面'};this.save()
+      } catch(error) {
+        this.state.job={id,state:'failed',message:error instanceof Error?error.message.slice(0,240):'本地虚拟机准备失败'};this.save()
+      } finally {
+        // Do not release maintenance when an interrupted verification VM may still be alive.
+        try { await this.cleanup(id);this.releaseMaintenance?.();this.releaseMaintenance=undefined } catch { this.state.job={id,state:'failed',message:'测试虚拟机停止状态待核对,请重启执行环境后重试'};this.save() }
+        this.pending=undefined
+      }
+    })()
+    return this.status()
+  }
+  async close() { this.closed=true;await this.ready;await this.pending }
+}

+ 70 - 0
src/runner/computers/maintenance.ts

@@ -0,0 +1,70 @@
+import {mkdir,readdir,lstat,readlink,readFile,writeFile,rm,realpath} from 'node:fs/promises'
+import {join,resolve,dirname,relative,basename} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {z} from 'zod'
+import {fileSHA256} from './images.js'
+import {recoverWorkspace,copyWorkspace,replaceWorkspaceContents} from './workspaceRecovery.js'
+import type {ComputerPool} from './pool.js'
+import type {ContainerComputerProvider,ComputerSpecification} from './container.js'
+const schema=z.object({protocol:z.literal(1),environmentId:z.string().uuid(),ownerKey:z.string(),imageId:z.string(),createdAt:z.number(),files:z.array(z.object({path:z.string(),kind:z.enum(['file','directory','symlink']),sha256:z.string().optional(),target:z.string().optional()}))}).strict()
+type Snapshot=z.infer<typeof schema>
+/** Offline maintenance: caller holds the exact Runner OS lock throughout. */
+export class ComputerMaintenance {
+  constructor(readonly pool:ComputerPool,readonly provider:ContainerComputerProvider,readonly home:string){}
+  private async stopped(spec:ComputerSpecification){
+    const row=this.pool.status(spec.ownerKey).find(row=>row.environmentId===spec.id)
+    if(!row||row.status!=='free')throw new Error('电脑停止状态未确认,不能维护工作区')
+    await this.provider.remove(spec)
+    const base=join(this.home,'computer-workspaces'),path=join(base,spec.id)
+    await recoverWorkspace(base,spec.id)
+    const [root,detail]=await Promise.all([realpath(base),lstat(path)])
+    if(!detail.isDirectory()||detail.isSymbolicLink()||await realpath(path)!==join(root,spec.id))throw new Error('电脑工作区路径不安全')
+    return path
+  }
+  private async inventory(root:string):Promise<Snapshot['files']>{
+    const files:Snapshot['files']=[];let bytes=0
+    const visit=async(directory:string)=>{
+      for(const name of (await readdir(directory)).sort()){
+        const path=join(directory,name),item=await lstat(path),key=relative(root,path)
+        if(files.length>=200000)throw new Error('工作区文件数量超过备份上限')
+        if(item.isSymbolicLink())files.push({path:key,kind:'symlink',target:await readlink(path)})
+        else if(item.isDirectory()){files.push({path:key,kind:'directory'});await visit(path)}
+        else if(item.isFile()){bytes+=item.size;if(bytes>20*1024**3)throw new Error('工作区超过 20 GiB 备份上限');files.push({path:key,kind:'file',sha256:await fileSHA256(path)})}
+        // Runtime sockets are intentionally omitted; applications recreate them.
+      }
+    }
+    await visit(root);return files
+  }
+  async backup(spec:ComputerSpecification,destination:string){
+    const source=await this.stopped(spec),target=resolve(destination)
+    if(target===resolve(source)||target.startsWith(resolve(source)+'/'))throw new Error('备份必须放在电脑工作区之外')
+    // Reserve the destination. Never overwrite an earlier snapshot.
+    await mkdir(target,{mode:0o700})
+    try{
+      await copyWorkspace(source,join(target,'workspace'))
+      const manifest:Snapshot={protocol:1,environmentId:spec.id,ownerKey:spec.ownerKey,imageId:spec.imageId,createdAt:Date.now(),files:await this.inventory(join(target,'workspace'))}
+      await writeFile(join(target,'snapshot.json'),JSON.stringify(manifest,null,2)+'\n',{flag:'wx',mode:0o600})
+      return {snapshot:target,files:manifest.files.length}
+    }catch(error){await rm(target,{recursive:true,force:true});throw error}
+  }
+  async restore(spec:ComputerSpecification,snapshot:string){
+    const workspace=await this.stopped(spec),source=resolve(snapshot),manifest=schema.parse(JSON.parse(await readFile(join(source,'snapshot.json'),'utf8')))
+    if(manifest.ownerKey!==spec.ownerKey||manifest.environmentId!==spec.id)throw new Error('备份不属于当前电脑及账号')
+    const sourceWorkspace=join(source,'workspace')
+    if((await lstat(sourceWorkspace)).isSymbolicLink())throw new Error('备份工作区不能是符号链接')
+    if(JSON.stringify(await this.inventory(sourceWorkspace))!==JSON.stringify(manifest.files))throw new Error('备份内容校验失败,未替换工作区')
+    const staging=join(dirname(workspace),`.restore-${randomUUID()}`),previous=join(dirname(workspace),`.before-restore-${spec.id}-${randomUUID()}`)
+    try{
+      await copyWorkspace(sourceWorkspace,staging)
+      if(JSON.stringify(await this.inventory(staging))!==JSON.stringify(manifest.files))throw new Error('恢复暂存校验失败')
+      await copyWorkspace(workspace,previous)
+      if(JSON.stringify(await this.inventory(previous))!==JSON.stringify(await this.inventory(workspace)))throw new Error('恢复前数据备份校验失败')
+      const journal=join(dirname(workspace),`.restore-${spec.id}.json`)
+      await writeFile(journal,JSON.stringify({protocol:1,mode:'copy',staging:basename(staging),previous:basename(previous)}),{flag:'wx',mode:0o600})
+      try{await replaceWorkspaceContents(staging,workspace)}catch(error){await recoverWorkspace(dirname(workspace),spec.id);throw error}
+      await rm(journal)
+      return {restored:workspace,previousWorkspace:previous,imageId:spec.imageId}
+    }finally{await rm(staging,{recursive:true,force:true})}
+  }
+  async rebuild(spec:ComputerSpecification){const workspace=await this.stopped(spec);return {workspace,rebuildOnNextStart:true,imageId:spec.imageId}}
+}

+ 34 - 0
src/runner/computers/maintenanceCli.ts

@@ -0,0 +1,34 @@
+import {parseArgs} from 'node:util'
+import {readFileSync,statSync} from 'node:fs'
+import {resolve,dirname,join} from 'node:path'
+import {DatabaseSync} from 'node:sqlite'
+import {z} from 'zod'
+import {parseRunnerConfiguration} from '../config.js'
+import {acquireServiceInstance} from '../../server/serviceInstance.js'
+import {ContainerComputerProvider} from './container.js'
+import {ComputerPool} from './pool.js'
+import {ComputerMaintenance} from './maintenance.js'
+let lock:ReturnType<typeof acquireServiceInstance>|undefined,db:DatabaseSync|undefined,pool:ComputerPool|undefined
+try{
+  const {values,positionals}=parseArgs({allowPositionals:true,options:{config:{type:'string'},home:{type:'string'},environment:{type:'string'},snapshot:{type:'string'}}})
+  if(!values.config||!['list','backup','restore','rebuild'].includes(positionals[0]??''))throw new Error('用法:runner-maintenance list|backup|restore|rebuild --config runner.json [--home Runner数据目录] [--environment UUID] [--snapshot 备份目录]')
+  if(process.platform!=='win32'&&(statSync(values.config).mode&0o077)!==0)throw new Error('执行节点配置必须仅当前用户可读')
+  const config=parseRunnerConfiguration(JSON.parse(readFileSync(values.config,'utf8'))),home=values.home?resolve(values.home):resolve(dirname(values.config),'state',config.runnerId)
+  if(!config.computers)throw new Error('该节点未配置电脑')
+  lock=acquireServiceInstance(home,'1.0.0')
+  db=new DatabaseSync(join(home,'runner-commands.sqlite3'))
+  const provider=new ContainerComputerProvider(config.computers.runtime,config.runnerId,home)
+  pool=new ComputerPool(db,provider);await pool.recover()
+  const entries=(db.prepare('SELECT value FROM computer_environments').all() as {value:string}[]).map(row=>JSON.parse(row.value))
+  let result
+  if(positionals[0]==='list')result=entries.map(row=>({environmentId:row.spec.id,status:row.status,imageId:row.spec.imageId}))
+  else{
+    const id=z.string().uuid().parse(values.environment),entry=entries.find(row=>row.spec.id===id)
+    if(!entry)throw new Error('当前 Runner 中没有这台电脑')
+    const maintenance=new ComputerMaintenance(pool,provider,home)
+    if(positionals[0]==='rebuild')result=await maintenance.rebuild(entry.spec)
+    else{if(!values.snapshot)throw new Error('需要 --snapshot 备份目录');result=positionals[0]==='backup'?await maintenance.backup(entry.spec,values.snapshot):await maintenance.restore(entry.spec,values.snapshot)}
+  }
+  process.stdout.write(JSON.stringify(result,null,2)+'\n')
+}catch(error){process.stderr.write(`${error instanceof Error?error.message:'电脑维护失败'}\n`);process.exitCode=1}
+finally{try{await pool?.close()}catch{process.stderr.write('尚未确认所有电脑已停止,请核对运行时\n');process.exitCode=1}db?.close();lock?.release()}

+ 207 - 0
src/runner/computers/pool.ts

@@ -0,0 +1,207 @@
+import {randomUUID} from 'node:crypto'
+import {DatabaseSync} from 'node:sqlite'
+import type {ComputerProvider,ComputerSpecification} from './container.js'
+import {ComputerError} from './container.js'
+
+export interface ComputerLease {id:string;environmentId:string;ownerKey:string;holderId:string;generation:number}
+type Status='free'|'idle'|'preparing'|'active'|'stopping'|'uncertain'
+interface Entry {spec:ComputerSpecification;generation:number;status:Status;lease?:ComputerLease;expiresAt:number;updatedAt:number}
+interface Active {lease:ComputerLease;authorize():void;controller:AbortController;operations:Set<Promise<unknown>>;detach():void}
+
+/** Durable exclusive use. A generation is invalidated before cancellation; no
+ * successor is admitted until the old private environment is confirmed stopped. */
+export class ComputerPool {
+  private active=new Map<string,Active>()
+  private changing=new Map<string,Promise<unknown>>()
+  private ready=false
+  private closing=false
+  private maintenance=false
+  holdMaintenance(){this.maintenance=true;let released=false;return ()=>{if(!released){released=true;this.maintenance=false}}}
+  beginMaintenance({allowIdle=false}={}){
+    if(this.maintenance||this.changing.size||this.rows().some(row=>row.status!=='free'&&!(allowIdle&&row.status==='idle')))throw new ComputerError('computer_busy',allowIdle?'请先停止正在使用虚拟机的任务并交还控制权,再修改虚拟机设置':'请先停止电脑任务并交还控制权,再管理镜像')
+    return this.holdMaintenance()
+  }
+  constructor(readonly db:DatabaseSync,readonly provider:ComputerProvider,readonly limits={concurrent:2,environments:32,ttlMs:30000},readonly now=Date.now) {
+    if(![limits.concurrent,limits.environments,limits.ttlMs].every(Number.isSafeInteger)||limits.concurrent<1||limits.concurrent>8||limits.environments<limits.concurrent||limits.environments>128||limits.ttlMs<1000||limits.ttlMs>120000)throw new Error('电脑资源配额无效')
+    db.exec('CREATE TABLE IF NOT EXISTS computer_environments(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
+  }
+  private rows():Entry[]{return (this.db.prepare('SELECT value FROM computer_environments').all() as {value:string}[]).map(row=>JSON.parse(row.value))}
+  private get(id:string):Entry|undefined {const row=this.db.prepare('SELECT value FROM computer_environments WHERE id=?').get(id) as {value:string}|undefined;return row?JSON.parse(row.value):undefined}
+  private save(entry:Entry){entry.updatedAt=this.now();this.db.prepare('INSERT INTO computer_environments VALUES(?,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value').run(entry.spec.id,JSON.stringify(entry))}
+  private serial<T>(id:string,action:()=>Promise<T>):Promise<T>{
+    const pending=(this.changing.get(id)??Promise.resolve()).catch(()=>{}).then(action)
+    this.changing.set(id,pending);void pending.finally(()=>{if(this.changing.get(id)===pending)this.changing.delete(id)}).catch(()=>{})
+    return pending
+  }
+  /** Must run under the Runner's OS instance lock before accepting commands. */
+  async recover():Promise<void>{
+    if(this.ready)throw new ComputerError('computer_pool_active','运行中的资源池不能重新执行启动恢复')
+    for(const entry of this.rows())if(entry.status!=='free')await this.serial(entry.spec.id,()=>this.stopEntry(entry)).catch(()=>{})
+    this.ready=true
+  }
+  status(ownerKey:string){return this.rows().filter(row=>row.spec.ownerKey===ownerKey).map(row=>({environmentId:row.spec.id,status:row.status,generation:row.generation,holderId:row.lease?.holderId,expiresAt:row.expiresAt}))}
+  private require(lease:ComputerLease,activeOnly=true):Entry {
+    const entry=this.get(lease.environmentId)
+    if(!entry||entry.spec.ownerKey!==lease.ownerKey||entry.lease?.id!==lease.id||entry.lease.holderId!==lease.holderId||entry.generation!==lease.generation||(activeOnly&&entry.status!=='active'))throw new ComputerError('computer_lease_stale','电脑控制权已改变,旧操作不能继续')
+    return entry
+  }
+  private assert(lease:ComputerLease){
+    const entry=this.require(lease),active=this.active.get(lease.id)
+    if(this.closing||!active||active.controller.signal.aborted||entry.expiresAt<=this.now())throw new ComputerError('computer_lease_expired','电脑控制授权已到期')
+    active.authorize()
+  }
+  async acquire(spec:ComputerSpecification,holderId:string,authorize:()=>void,signal?:AbortSignal):Promise<ComputerLease>{
+    spec=this.provider.validateSpecification(spec)
+    if(!holderId||holderId.length>256)throw new ComputerError('computer_holder_invalid','电脑任务身份无效')
+    return this.serial(spec.id,async()=>{
+      authorize()
+      if(this.maintenance)throw new ComputerError('computer_busy','执行节点正在管理镜像,请稍后重试')
+      if(!this.ready||this.closing)throw new ComputerError('computer_pool_unavailable','电脑资源正在恢复或关闭')
+      let entry=this.get(spec.id)
+      if(entry&&(entry.spec.ownerKey!==spec.ownerKey||JSON.stringify(entry.spec)!==JSON.stringify(spec)))throw new ComputerError('computer_owner_mismatch','电脑环境归属或配置不匹配')
+      if(entry?.status==='active'&&entry.lease?.holderId===holderId&&this.active.has(entry.lease.id)){this.assert(entry.lease);return entry.lease}
+      if(entry&&entry.status!=='free'&&entry.status!=='idle')throw new ComputerError('computer_busy','电脑正在使用或需要核对停止状态')
+      const rows=this.rows()
+      if((!entry&&rows.length>=this.limits.environments)||rows.filter(row=>row.status!=='free'&&row.spec.id!==spec.id).length>=this.limits.concurrent)throw new ComputerError('computer_quota','本地虚拟机数量已达上限,请先停止另一台桌面')
+      const lease:ComputerLease={id:randomUUID(),environmentId:spec.id,ownerKey:spec.ownerKey,holderId,generation:(entry?.generation??0)+1}
+      entry={spec:structuredClone(spec),lease,generation:lease.generation,status:'preparing',expiresAt:this.now()+this.limits.ttlMs,updatedAt:this.now()}
+      // This reservation is synchronous, before any provider await or next acquire.
+      this.save(entry)
+      const abort=()=>{const current=this.get(spec.id);if(current?.lease?.id===lease.id)this.invalidate(current)}
+      const active:Active={lease,authorize,controller:new AbortController(),operations:new Set(),detach:()=>signal?.removeEventListener('abort',abort)};this.active.set(lease.id,active)
+      signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort()
+      const guard=()=>{const current=this.require(lease,false);if(this.closing||active.controller.signal.aborted||current.status!=='preparing'||current.expiresAt<=this.now())throw new ComputerError('computer_lease_expired','电脑启动授权已失效');authorize()}
+      try{
+        await this.provider.ensure(spec,guard);guard()
+        entry.status='active';this.save(entry);return lease
+      }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.maintenance)throw new ComputerError('computer_busy','执行节点正在管理镜像,请稍后重试')
+      if(!this.ready||this.closing)throw new ComputerError('computer_pool_unavailable','电脑资源正在恢复或关闭')
+      const entry=this.get(spec.id);if(!entry)return
+      if(entry.spec.ownerKey!==spec.ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
+      if(JSON.stringify(entry.spec)===JSON.stringify(spec))return
+      if(entry.status!=='free'&&entry.status!=='idle')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}
+    })
+  }
+  async transfer(lease:ComputerLease,holderId:string,authorize:()=>void):Promise<ComputerLease>{
+    return this.serial(lease.environmentId,async()=>{
+      this.assert(lease);authorize()
+      const active=this.active.get(lease.id)!
+      if(active.operations.size)throw new ComputerError('computer_busy','电脑仍有在途操作,不能移交控制')
+      const entry=this.require(lease),state=await this.provider.inspect(entry.spec)
+      this.assert(lease);authorize()
+      if(active.operations.size||!state?.running)throw new ComputerError('computer_busy','电脑尚未就绪,不能移交控制')
+      const next:ComputerLease={...lease,id:randomUUID(),holderId,generation:entry.generation+1}
+      active.detach();active.controller.abort();this.active.delete(lease.id)
+      this.active.set(next.id,{lease:next,authorize,controller:new AbortController(),operations:new Set(),detach:()=>{}})
+      entry.lease=next;entry.generation=next.generation;entry.expiresAt=this.now()+this.limits.ttlMs;this.save(entry)
+      return next
+    })
+  }
+  authorize(lease:ComputerLease){this.assert(lease)}
+  renew(lease:ComputerLease):void{this.assert(lease);const entry=this.require(lease);entry.expiresAt=this.now()+this.limits.ttlMs;this.save(entry)}
+  use<T>(lease:ComputerLease,action:(context:{signal:AbortSignal;authorize():void})=>Promise<T>):Promise<T>{
+    this.assert(lease)
+    const active=this.active.get(lease.id)!
+    const pending=Promise.resolve().then(()=>{this.assert(lease);return action({signal:active.controller.signal,authorize:()=>this.assert(lease)})}).then(value=>{this.assert(lease);return value})
+    active.operations.add(pending);void pending.finally(()=>active.operations.delete(pending)).catch(()=>{})
+    return pending
+  }
+  release(lease:ComputerLease,keepRunning=false):Promise<void>{
+    if(keepRunning)return this.serial(lease.environmentId,async()=>{
+      this.assert(lease)
+      const entry=this.require(lease),active=this.active.get(lease.id)!
+      if(active.operations.size)throw new ComputerError('computer_busy','电脑仍有未结束操作,暂不能进入空闲状态')
+      active.detach();active.controller.abort();this.active.delete(lease.id)
+      entry.status='idle';entry.generation++;entry.lease=undefined;entry.expiresAt=0;this.save(entry)
+    })
+    const existing=this.get(lease.environmentId)
+    const entry=existing&&existing.lease?.id===lease.id&&existing.spec.ownerKey===lease.ownerKey&&existing.lease.holderId===lease.holderId&&['stopping','uncertain'].includes(existing.status)?existing:this.require(lease,false)
+    this.invalidate(entry)
+    return this.serial(entry.spec.id,async()=>{const current=this.get(entry.spec.id)!;if(current.status==='free')return;await this.stopEntry(current)})
+  }
+  definition(ownerKey:string,id:string){const entry=this.get(id);if(entry&&entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配');return entry?.spec}
+  async desktop(spec:ComputerSpecification,action:'create'|'start'|'stop'|'recreate'|'remove',authorize:()=>void){
+    spec=this.provider.validateSpecification(spec)
+    await this.serial(spec.id,async()=>{
+      authorize()
+      if(!this.ready||this.closing||this.maintenance)throw new ComputerError('computer_busy','本地虚拟机正在维护')
+      let entry=this.get(spec.id)
+      if(entry?.spec.ownerKey!==undefined&&entry.spec.ownerKey!==spec.ownerKey)throw new ComputerError('computer_owner_mismatch','虚拟机归属不匹配')
+      if(entry&&!['free','idle'].includes(entry.status))throw new ComputerError('computer_busy','请先停止 Agent 任务并交还控制权')
+      const starts=['create','start','recreate'].includes(action)
+      if(starts&&this.rows().filter(row=>row.status!=='free'&&row.spec.id!==spec.id).length>=this.limits.concurrent)throw new ComputerError('computer_quota','本地虚拟机数量已达上限,请先停止另一台桌面')
+      if(!entry&&!starts)return
+      entry??={spec,generation:0,status:'free',expiresAt:0,updatedAt:this.now()}
+      entry.status='preparing';entry.generation++;this.save(entry)
+      try {
+        if(['stop','recreate','remove'].includes(action)) {await this.provider.stop(entry.spec);await this.provider.remove(entry.spec)}
+        authorize()
+        if(starts){entry.spec=spec;await this.provider.ensure(spec,authorize);entry.status='idle'}else entry.status='free'
+        if(action==='remove')this.db.prepare('DELETE FROM computer_environments WHERE id=?').run(spec.id)
+        else this.save(entry)
+      }catch(error){entry.status='uncertain';this.save(entry);throw error}
+    })
+  }
+  async retire(ownerKey:string,id:string):Promise<ComputerSpecification|undefined>{
+    return this.serial(id,async()=>{
+      const entry=this.get(id);if(!entry)return
+      if(entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
+      if(entry.status!=='free'&&entry.status!=='idle')throw new ComputerError('computer_busy','电脑停止状态尚未确认')
+      if(entry.status==='idle')await this.provider.stop(entry.spec)
+      await this.provider.remove(entry.spec)
+      this.db.prepare('DELETE FROM computer_environments WHERE id=?').run(id)
+      return entry.spec
+    })
+  }
+  async stopHolder(ownerKey:string,id:string,holderId:string):Promise<void>{
+    const entry=this.get(id)
+    if(!entry||entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
+    if(entry.lease?.holderId!==holderId)return
+    this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry))
+  }
+  private invalidate(expected:Entry){
+    const entry=this.get(expected.spec.id)
+    if(!entry||entry.lease?.id!==expected.lease?.id||entry.status==='free')return
+    if(entry.status!=='stopping'){entry.generation++;entry.status='stopping';entry.expiresAt=0;this.save(entry)}
+    const active=entry.lease?this.active.get(entry.lease.id):undefined
+    active?.controller.abort()
+    return entry
+  }
+  private async stopEntry(expected:Entry):Promise<void>{
+    const entry=this.invalidate(expected)
+    if(!entry)return
+    const active=entry.lease?this.active.get(entry.lease.id):undefined
+    try{
+      await this.provider.stop(entry.spec)
+      // Retire the runtime identity as well as the lease generation. A delayed
+      // exec targeting the old container ID can never enter its successor.
+      await this.provider.remove(entry.spec)
+      active?.detach();if(entry.lease)this.active.delete(entry.lease.id)
+      entry.status='free';entry.lease=undefined;entry.expiresAt=0;this.save(entry)
+    }catch(error){entry.status='uncertain';this.save(entry);throw error}
+  }
+  async expire():Promise<void>{
+    for(const id of this.rows().map(entry=>entry.spec.id)){
+      const entry=this.get(id)!
+      if(entry.status==='idle'&&this.now()-entry.updatedAt>=300000){this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{});continue}
+      if((entry.status==='active'||entry.status==='preparing')&&entry.expiresAt<=this.now()){
+        this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{})
+      }
+    }
+  }
+  async close():Promise<void>{
+    this.closing=true
+    for(const entry of this.rows())if(entry.status!=='free')this.invalidate(entry)
+    await Promise.allSettled(this.rows().filter(entry=>entry.status!=='free').map(entry=>this.serial(entry.spec.id,()=>this.stopEntry(this.get(entry.spec.id)!))))
+    if(this.rows().some(entry=>entry.status!=='free'))throw new ComputerError('computer_stop_uncertain','尚未确认所有电脑环境已停止')
+  }
+}

+ 32 - 0
src/runner/computers/workspaceRecovery.ts

@@ -0,0 +1,32 @@
+import {readFile,rename,rm,lstat,readdir,cp} from 'node:fs/promises'
+import {join} from 'node:path'
+import {z} from 'zod'
+const uuid='[0-9a-f-]{36}'
+export async function copyWorkspace(source:string,target:string){
+  await cp(source,target,{recursive:true,dereference:false,verbatimSymlinks:true,filter:async path=>{const item=await lstat(path);return item.isFile()||item.isDirectory()||item.isSymbolicLink()}})
+}
+export async function replaceWorkspaceContents(source:string,target:string){
+  const item=await lstat(target)
+  if(!item.isDirectory()||item.isSymbolicLink())throw new Error('恢复目录不安全')
+  for(const name of await readdir(target))await rm(join(target,name),{recursive:true,force:true})
+  await copyWorkspace(source,target)
+}
+/** Keep the bind-mount root inode stable on macOS shared filesystems. */
+export async function recoverWorkspace(base:string,id:string){
+  z.string().uuid().parse(id)
+  const journal=join(base,`.restore-${id}.json`)
+  let value:unknown
+  try{value=JSON.parse(await readFile(journal,'utf8'))}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return;throw error}
+  const record=z.object({protocol:z.literal(1),mode:z.literal('copy').optional(),staging:z.string().regex(new RegExp(`^\\.restore-${uuid}$`)),previous:z.string().regex(new RegExp(`^\\.before-restore-${id}-${uuid}$`))}).strict().parse(value)
+  const workspace=join(base,id),previous=join(base,record.previous),staging=join(base,record.staging)
+  const exists=async(path:string)=>{try{const stat=await lstat(path);if(!stat.isDirectory()||stat.isSymbolicLink())throw new Error('恢复目录不安全');return true}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return false;throw error}}
+  if(record.mode==='copy'){
+    if(!await exists(previous))throw new Error('恢复日志中的原工作区缺失,需要手动核对')
+    if(await exists(workspace))await replaceWorkspaceContents(previous,workspace)
+    else await copyWorkspace(previous,workspace)
+  }else if(!await exists(workspace)){
+    if(!await exists(previous))throw new Error('恢复日志中的原工作区缺失,需要手动核对')
+    await rename(previous,workspace)
+  }
+  await rm(staging,{recursive:true,force:true});await rm(journal)
+}

+ 21 - 0
src/runner/config.ts

@@ -0,0 +1,21 @@
+import { isAbsolute } from 'node:path'
+import { z } from 'zod'
+import type { RunnerConfiguration } from '../shared/runner.js'
+
+const schema=z.object({
+  protocol:z.literal(1),serverURL:z.string().url(),runnerId:z.string().uuid(),token:z.string().min(32).max(4096),
+  hermesURL:z.string().url().default('http://127.0.0.1:9119'),
+  allowedProfiles:z.array(z.string().trim().min(1).max(256).regex(/^[^/\\\u0000-\u001f]+$/)).min(1).max(256),
+  artifactRoots:z.array(z.string().refine(isAbsolute,'产物目录必须为绝对路径')).max(64).default([]),
+  computers:z.object({network:z.enum(['none','public-proxy']).default('none'),runtime:z.enum(['docker','podman']),imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),python:z.string().refine(isAbsolute),hermesSource:z.string().refine(isAbsolute),hermesHome:z.string().refine(isAbsolute),maxConcurrent:z.number().int().min(1).max(8).optional()}).strict().optional(),
+  allowInsecureLan:z.boolean().optional(),hermesCredentials:z.object({username:z.string().max(256),password:z.string().max(4096)}).strict().optional(),
+}).strict()
+export function parseRunnerConfiguration(value:unknown):RunnerConfiguration {
+  const parsed=schema.safeParse(value)
+  if(!parsed.success)throw new Error('执行节点配置无效,请检查字段格式')
+  const config=parsed.data,web=new URL(config.serverURL),hermes=new URL(config.hermesURL)
+  if(web.username||web.password||web.search||web.hash||web.pathname!=='/'||!['http:','https:'].includes(web.protocol))throw new Error('夭夭服务地址无效')
+  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')
+  return config
+}

+ 53 - 0
src/runner/index.ts

@@ -0,0 +1,53 @@
+import { readFileSync, statSync, mkdirSync } from 'node:fs'
+import { resolve, dirname, isAbsolute, join } from 'node:path'
+import type { ParentPort } from 'electron'
+import { RunnerAgent } from './agent.js'
+import { parseRunnerConfiguration } from './config.js'
+import { acquireServiceInstance } from '../server/serviceInstance.js'
+
+const controller=new AbortController()
+process.once('SIGTERM',()=>controller.abort());process.once('SIGINT',()=>controller.abort())
+const parent=(process as unknown as {parentPort?:ParentPort}).parentPort
+async function input():Promise<{config:ReturnType<typeof parseRunnerConfiguration>;home:string}> {
+  if(process.argv.includes('--desktop-ipc')) {
+    if(!parent)throw new Error('桌面配置只允许通过 App 私有进程通道传入')
+    return new Promise((resolveInput,reject)=>{
+      const timer=setTimeout(()=>reject(new Error('未收到桌面配置')),10000)
+      parent.once('message',event=>{
+        clearTimeout(timer)
+        try {
+          const data=event.data
+          if(data?.type!=='configure'||typeof data.home!=='string'||!isAbsolute(data.home))throw new Error('桌面配置无效')
+          const config=parseRunnerConfiguration(data.config)
+          resolveInput({config,home:join(data.home,config.runnerId)})
+        }catch{reject(new Error('桌面配置无效'))}
+      })
+    })
+  }
+  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)')
+  let value:unknown
+  try{value=JSON.parse(readFileSync(path,'utf8'))}catch{throw new Error('无法读取执行节点 JSON 配置')}
+  const config=parseRunnerConfiguration(value)
+  return {config,home:resolve(dirname(path),'state',config.runnerId)}
+}
+try {
+  const {config,home}=await input()
+  parent?.on('message',event=>{if(event.data?.type==='shutdown')controller.abort()})
+  mkdirSync(home,{recursive:true,mode:0o700})
+  const instance=acquireServiceInstance(home,'1.0.0')
+  process.once('exit',()=>instance.release())
+  const agent=new RunnerAgent(config,home,fetch,message=>{
+    if(parent)parent.postMessage({type:'status',message})
+    else process.stderr.write(`${new Date().toISOString()} ${message}\n`)
+  })
+  try{await agent.run(controller.signal)}finally{instance.release()}
+}catch(error){
+  if(parent)parent.postMessage({type:'error',message:(error as {code?:string})?.code==='computer_stop_uncertain'?'尚未确认所有电脑环境已停止,请核对容器运行时':'执行节点启动或运行失败,请检查配置'})
+  else process.stderr.write(`${error instanceof Error?error.message:'执行节点启动失败'}\n`)
+  process.exitCode=1
+}
+
+// The utility IPC listener keeps Electron children alive; exit only after cleanup.
+if(parent)process.exit(process.exitCode ?? 0)

+ 66 - 0
src/runner/network/computerProxy.ts

@@ -0,0 +1,66 @@
+import {readFile} from 'node:fs/promises'
+import type {ChildProcessWithoutNullStreams} from 'node:child_process'
+import {PublicSocketBroker,type NetworkFrame} from './socketBroker.js'
+import type {ContainerComputerProvider,ComputerSpecification} from '../computers/container.js'
+
+/** One private pipe per leased computer. This exposes no host listening port. */
+export class ComputerPublicProxy {
+  private broker:PublicSocketBroker
+  private stopping=false
+  private buffer=''
+  private exited:Promise<void>
+  private ready:Promise<void>
+  private resolveReady!:()=>void
+  private rejectReady!:(error:Error)=>void
+  private lifetimeTimer:ReturnType<typeof setTimeout>
+  private tasks=new Set<Promise<void>>()
+  private constructor(readonly child:ChildProcessWithoutNullStreams,authorize:()=>void,check:()=>Promise<void>,readonly onFailure:()=>void){
+    this.ready=new Promise((resolve,reject)=>{this.resolveReady=resolve;this.rejectReady=reject});void this.ready.catch(()=>{})
+    this.broker=new PublicSocketBroker(frame=>this.send(frame),authorize,undefined,undefined,undefined,check)
+    child.stderr.resume();child.stdin.on('error',()=>this.fail())
+    this.exited=new Promise(resolve=>{
+      const ended=()=>{resolve();if(!this.stopping)this.fail()}
+      child.once('exit',ended);child.once('error',ended)
+    })
+    child.stdout.on('data',chunk=>{
+      this.buffer+=String(chunk)
+      if(this.buffer.length>1024*1024){this.fail();return}
+      for(;;){const end=this.buffer.indexOf('\n');if(end<0)break;const line=this.buffer.slice(0,end);this.buffer=this.buffer.slice(end+1)
+        try{
+          const frame=JSON.parse(line)
+          if(frame.op==='ready'&&frame.port===3128){this.resolveReady();continue}
+          const task=this.broker.receive(frame).catch(()=>this.fail())
+          this.tasks.add(task);void task.finally(()=>this.tasks.delete(task))
+        }catch{this.fail()}
+      }
+    })
+    this.lifetimeTimer=setTimeout(()=>this.fail(),30*60*1000);this.lifetimeTimer.unref()
+  }
+  static async start(provider:ContainerComputerProvider,spec:ComputerSpecification,script:string,authorize:()=>void,check:()=>Promise<void>,onFailure:()=>void){
+    authorize()
+    const source=await readFile(script,'utf8')
+    authorize();await provider.configureNetwork(spec,authorize)
+    const child=await provider.openPipe(spec,['python3','-u','-c',source],authorize)
+    const proxy=new ComputerPublicProxy(child,authorize,check,onFailure)
+    let timer:ReturnType<typeof setTimeout>|undefined
+    try{await Promise.race([proxy.ready,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('电脑联网代理启动超时')),10000)})]);authorize();return proxy}
+    catch(error){await proxy.close();throw error}finally{clearTimeout(timer)}
+  }
+  private send(frame:NetworkFrame):Promise<void>{
+    if(this.stopping||this.child.stdin.destroyed)return Promise.reject(new Error('联网通道已关闭'))
+    const line=JSON.stringify(frame)+'\n'
+    if(this.child.stdin.writableLength+line.length>4*1024*1024)return Promise.reject(new Error('联网回传队列已满'))
+    return new Promise((resolve,reject)=>this.child.stdin.write(line,error=>error?reject(error):resolve()))
+  }
+  private fail(){if(this.stopping)return;this.rejectReady(new Error('电脑联网通道已断开'));void this.close();this.onFailure()}
+  async close(){
+    if(this.stopping)return this.exited
+    this.stopping=true;clearTimeout(this.lifetimeTimer);this.broker.close();this.child.stdin.end()
+    let timer:ReturnType<typeof setTimeout>|undefined
+    try{await Promise.race([this.exited,new Promise(resolve=>{timer=setTimeout(resolve,1500)})])}finally{clearTimeout(timer)}
+    if(this.child.exitCode===null&&this.child.signalCode===null)this.child.kill('SIGTERM')
+    try{await Promise.race([this.exited,new Promise(resolve=>{timer=setTimeout(resolve,1500)})])}finally{clearTimeout(timer)}
+    if(this.child.exitCode===null&&this.child.signalCode===null)this.child.kill('SIGKILL')
+    await this.exited
+  }
+}

+ 116 - 0
src/runner/network/guest_proxy.py

@@ -0,0 +1,116 @@
+"""Loopback HTTP proxy. All external sockets are owned by the parent broker.
+This process has no network interface except guest loopback and no credentials.
+"""
+import asyncio
+import base64
+import json
+import sys
+import uuid
+from urllib.parse import urlsplit
+
+PORT = 3128
+streams = {}
+wire = None
+wire_lock = None
+
+async def emit(frame):
+    async with wire_lock:
+        wire.write((json.dumps(frame, separators=(",", ":")) + "\n").encode())
+        await wire.drain()
+
+async def client(reader, writer):
+    if len(streams) >= 32:
+        writer.close()
+        return
+    identity = str(uuid.uuid4())
+    opened = asyncio.get_running_loop().create_future()
+    streams[identity] = (writer, opened)
+    connected = False
+    try:
+        header = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 10)
+        if len(header) > 32768:
+            raise ValueError("header too large")
+        first, rest = header.split(b"\r\n", 1)
+        method, target, version = first.decode("ascii").split(" ")
+        if version not in ("HTTP/1.0", "HTTP/1.1"):
+            raise ValueError("invalid protocol")
+        parsed = urlsplit(("//" if method == "CONNECT" else "") + target)
+        if not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
+            raise ValueError("invalid authority")
+        if method != "CONNECT" and parsed.scheme != "http":
+            raise ValueError("use CONNECT for HTTPS")
+        port = parsed.port or (443 if method == "CONNECT" else 80)
+        await emit({"op": "open", "id": identity, "host": parsed.hostname, "port": port})
+        await asyncio.wait_for(opened, 15)
+        connected = True
+        if method == "CONNECT":
+            writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
+            await writer.drain()
+        else:
+            path = parsed.path or "/"
+            if parsed.query:
+                path += "?" + parsed.query
+            lines = [line for line in rest.split(b"\r\n") if line and line.split(b":", 1)[0].lower() not in (b"proxy-authorization", b"proxy-connection", b"connection", b"host")]
+            authority = ("[" + parsed.hostname + "]") if ":" in parsed.hostname else parsed.hostname
+            if port != 80:
+                authority += ":" + str(port)
+            outgoing = (method + " " + path + " " + version + "\r\nHost: " + authority + "\r\nConnection: close\r\n").encode() + b"\r\n".join(lines) + b"\r\n\r\n"
+            await emit({"op": "data", "id": identity, "data": base64.b64encode(outgoing).decode()})
+        while True:
+            data = await reader.read(32768)
+            if not data:
+                await emit({"op": "end", "id": identity})
+                break
+            await emit({"op": "data", "id": identity, "data": base64.b64encode(data).decode()})
+    except Exception:
+        if not connected:
+            writer.write(b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
+            with __import__("contextlib").suppress(Exception):
+                await writer.drain()
+    finally:
+        streams.pop(identity, None)
+        writer.close()
+        with __import__("contextlib").suppress(Exception):
+            await emit({"op": "close", "id": identity})
+
+async def main():
+    global wire, wire_lock
+    loop = asyncio.get_running_loop()
+    transport, protocol = await loop.connect_write_pipe(asyncio.streams.FlowControlMixin, sys.stdout.buffer)
+    wire = asyncio.StreamWriter(transport, protocol, None, loop)
+    wire_lock = asyncio.Lock()
+    incoming = asyncio.StreamReader(limit=100000)
+    await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(incoming), sys.stdin.buffer)
+    server = await asyncio.start_server(client, "127.0.0.1", PORT, limit=32768)
+    await emit({"op": "ready", "port": PORT})
+    try:
+        while True:
+            line = await incoming.readline()
+            if not line:
+                break
+            frame = json.loads(line)
+            state = streams.get(frame.get("id"))
+            if not state:
+                continue
+            writer, opened = state
+            if frame["op"] == "opened":
+                if not opened.done():
+                    opened.set_result(True)
+            elif frame["op"] == "data":
+                writer.write(base64.b64decode(frame["data"], validate=True))
+                await writer.drain()
+            elif frame["op"] in ("close", "error", "end"):
+                if not opened.done():
+                    opened.set_exception(ConnectionError("network unavailable"))
+                elif not opened.cancelled() and opened.exception() is None:
+                    writer.close()
+    finally:
+        server.close()
+        await server.wait_closed()
+        for writer, opened in list(streams.values()):
+            if not opened.done():
+                opened.cancel()
+            writer.close()
+        wire.close()
+
+asyncio.run(main())

+ 37 - 0
src/runner/network/publicDestination.ts

@@ -0,0 +1,37 @@
+import {lookup} from 'node:dns/promises'
+import {BlockList,isIP} from 'node:net'
+import {networkInterfaces} from 'node:os'
+
+// Conservative public-web policy. Special-use registries:
+// https://www.iana.org/assignments/iana-ipv4-special-registry/
+// https://www.iana.org/assignments/iana-ipv6-special-registry/
+const denied4=new BlockList(),denied6=new BlockList(),global6=new BlockList()
+for(const [address,prefix] of [
+  ['0.0.0.0',8],['10.0.0.0',8],['100.64.0.0',10],['127.0.0.0',8],['169.254.0.0',16],
+  ['172.16.0.0',12],['192.0.0.0',24],['192.0.2.0',24],['192.88.99.0',24],['192.168.0.0',16],
+  ['198.18.0.0',15],['198.51.100.0',24],['203.0.113.0',24],['224.0.0.0',4],['240.0.0.0',4],
+] as const)denied4.addSubnet(address,prefix,'ipv4')
+global6.addSubnet('2000::',3,'ipv6')
+for(const [address,prefix] of [['2001::',23],['2001:db8::',32],['2002::',16],['3fff::',20]] as const)denied6.addSubnet(address,prefix,'ipv6')
+export function publicAddress(address:string):boolean {
+  const family=isIP(address)
+  if(family===4)return !denied4.check(address,'ipv4')
+  return family===6&&global6.check(address,'ipv6')&&!denied6.check(address,'ipv6')
+}
+export function normalizePublicHost(value:string):string {
+  if(!value||value.length>253||/[\s\u0000-\u001f/%\\@?#]/.test(value))throw new Error('网络目标无效')
+  const url=new URL(`http://${isIP(value)===6?`[${value}]`:value}`)
+  if(url.port||url.username||url.password||url.pathname!=='/')throw new Error('网络目标无效')
+  return url.hostname.replace(/^\[|\]$/g,'').replace(/\.$/,'').toLowerCase()
+}
+export type AddressLookup=(host:string)=>Promise<Array<{address:string;family:number}>>
+export async function publicDestination(host:string,port:number,resolve:AddressLookup=host=>lookup(host,{all:true,verbatim:true}),local=networkInterfaces()):Promise<{host:string;address:string;family:4|6;port:number}> {
+  if(port!==80&&port!==443)throw new Error('电脑联网仅允许公网 HTTP 和 HTTPS')
+  host=normalizePublicHost(host)
+  const addresses=isIP(host)?[{address:host,family:isIP(host)}]:await resolve(host)
+  const own=new BlockList()
+  for(const entries of Object.values(local))for(const entry of entries??[]){const family=isIP(entry.address);if(family)own.addAddress(entry.address,family===4?'ipv4':'ipv6')}
+  if(!addresses.length||addresses.some(item=>!publicAddress(item.address)||own.check(item.address,item.family===4?'ipv4':'ipv6')))throw new Error('电脑不能访问内网、宿主或保留地址')
+  const selected=addresses.find(item=>item.family===4)??addresses[0]!
+  return {host,address:selected.address,family:selected.family as 4|6,port}
+}

+ 65 - 0
src/runner/network/socketBroker.ts

@@ -0,0 +1,65 @@
+import {connect,type Socket} from 'node:net'
+import {z} from 'zod'
+import {publicDestination} from './publicDestination.js'
+
+export type NetworkFrame={op:string;id?:string;[key:string]:unknown}
+const input=z.discriminatedUnion('op',[
+  z.object({op:z.literal('open'),id:z.string().uuid(),host:z.string().max(253),port:z.number().int()}).strict(),
+  z.object({op:z.literal('data'),id:z.string().uuid(),data:z.string().max(65536)}).strict(),
+  z.object({op:z.literal('end'),id:z.string().uuid()}).strict(),
+  z.object({op:z.literal('close'),id:z.string().uuid()}).strict(),
+])
+type Destination=Awaited<ReturnType<typeof publicDestination>>
+interface Stream {socket?:Socket;received:number;sent:number;opened:boolean}
+export class PublicSocketBroker {
+  private streams=new Map<string,Stream>()
+  private closed=false
+  private transferred=0
+  private timer:ReturnType<typeof setInterval>
+  constructor(readonly send:(frame:NetworkFrame)=>Promise<void>,readonly authorize:()=>void,readonly resolve=publicDestination,readonly dial:(destination:Destination)=>Socket=target=>connect({host:target.address,family:target.family,port:target.port}),readonly limits={connections:16,bytes:256*1024*1024},readonly authorizeOpen:()=>Promise<void>=async()=>authorize()){
+    this.timer=setInterval(()=>{try{authorize()}catch{this.close()}},1000);this.timer.unref()
+  }
+  get activeConnections(){return this.streams.size}
+  async receive(value:unknown):Promise<void>{
+    const frame=input.parse(value)
+    if(this.closed)throw new Error('联网通道已关闭')
+    this.authorize()
+    if(frame.op==='open'){
+      if(this.streams.has(frame.id))throw new Error('连接编号已被使用')
+      if(this.streams.size>=this.limits.connections){await this.send({op:'error',id:frame.id,message:'联网连接已达上限'});return}
+      const state:Stream={received:0,sent:0,opened:false};this.streams.set(frame.id,state)
+      try{
+        await this.authorizeOpen()
+        const target=await this.resolve(frame.host,frame.port)
+        await this.authorizeOpen();this.authorize();if(this.closed||this.streams.get(frame.id)!==state)return
+        const socket=this.dial(target);state.socket=socket
+        socket.setTimeout(30000,()=>socket.destroy())
+        socket.once('connect',()=>{try{this.authorize();if(this.closed)throw new Error('closed');state.opened=true;void this.send({op:'opened',id:frame.id}).catch(()=>this.close())}catch{socket.destroy()}})
+        socket.on('data',chunk=>{
+          socket.pause()
+          try{this.authorize();this.count(chunk.length);state.received+=chunk.length}
+          catch{this.close();return}
+          void this.send({op:'data',id:frame.id,data:chunk.toString('base64')}).then(()=>socket.resume(),()=>this.close())
+        })
+        socket.once('end',()=>{void this.send({op:'end',id:frame.id}).catch(()=>this.close())})
+        socket.once('error',()=>{void this.send({op:'error',id:frame.id,message:'公网连接失败'}).catch(()=>this.close())})
+        socket.once('close',()=>{if(this.streams.get(frame.id)===state)this.streams.delete(frame.id);void this.send({op:'close',id:frame.id}).catch(()=>this.close())})
+      }catch{
+        this.streams.delete(frame.id)
+        await this.send({op:'error',id:frame.id,message:'联网目标被拒绝或不可达'})
+      }
+      return
+    }
+    const stream=this.streams.get(frame.id)
+    if(!stream)return
+    if(frame.op==='close'){this.streams.delete(frame.id);stream.socket?.destroy();return}
+    if(!stream.opened||!stream.socket)throw new Error('连接尚未建立')
+    if(frame.op==='end'){stream.socket.end();return}
+    const bytes=Buffer.from(frame.data,'base64')
+    this.count(bytes.length);stream.sent+=bytes.length
+    if(stream.socket.writableLength+bytes.length>1024*1024)throw new Error('联网写入队列过长')
+    await new Promise<void>((resolve,reject)=>stream.socket!.write(bytes,error=>error?reject(error):resolve()))
+  }
+  private count(bytes:number){this.transferred+=bytes;if(this.transferred>this.limits.bytes)throw new Error('本轮联网流量已达上限')}
+  close(){if(this.closed)return;this.closed=true;clearInterval(this.timer);for(const stream of this.streams.values())stream.socket?.destroy();this.streams.clear()}
+}

+ 132 - 0
src/runner/worker/control.ts

@@ -0,0 +1,132 @@
+import {randomUUID,createHash} from 'node:crypto'
+import {z} from 'zod'
+import {HttpError} from '../../server/errors.js'
+import {CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
+import type {ComputerLease} from '../computers/pool.js'
+import {ComputerPublicProxy} from '../network/computerProxy.js'
+import type {ComputerRuntime,ComputerGateway,ComputerTarget} from './gateway.js'
+import type {ComputerControlStatus,ComputerFrame} from '../../shared/computerControl.js'
+
+const actionSchema=z.discriminatedUnion('kind',[
+  z.object({kind:z.literal('click'),x:z.number().int().nonnegative(),y:z.number().int().nonnegative(),button:z.enum(['left','right','middle']).default('left'),count:z.number().int().min(1).max(2).default(1)}).strict(),
+  z.object({kind:z.literal('drag'),fromX:z.number().int().nonnegative(),fromY:z.number().int().nonnegative(),toX:z.number().int().nonnegative(),toY:z.number().int().nonnegative()}).strict(),
+  z.object({kind:z.literal('text'),text:z.string().max(16000)}).strict(),
+  z.object({kind:z.literal('key'),key:z.string().min(1).max(40).regex(/^[A-Za-z0-9_+ -]+$/),modifiers:z.array(z.enum(['ctrl','alt','shift','super'])).max(4).default([])}).strict(),
+  z.object({kind:z.literal('scroll'),direction:z.enum(['up','down','left','right']),amount:z.number().int().min(1).max(50).default(3)}).strict(),
+])
+interface Manual {
+  id:string;meta:ComputerTarget;profile:string;mode:ComputerControlStatus['mode'];valid:boolean
+  gateway?:ComputerGateway;lease?:ComputerLease;spec?:ComputerSpecification;proxy?:ComputerPublicProxy
+  pending?:Promise<void>;error?:string;check():Promise<void>;checking:boolean
+  stopping?:Promise<void>;actions:Map<string,{fingerprint:string;result:Promise<unknown>}>
+}
+export class ComputerControls {
+  private manual=new Map<string,Manual>()
+  private frames=new Map<string,ComputerFrame[]>()
+  private captures=new Map<string,Promise<ComputerFrame>>()
+  private timer:ReturnType<typeof setInterval>
+  constructor(readonly runtime:ComputerRuntime){
+    this.timer=setInterval(()=>{for(const state of this.manual.values()){
+      if(state.checking)continue;state.checking=true
+      void state.check().then(()=>{if(state.lease&&state.mode==='human')this.runtime.pool.renew(state.lease)}).catch(()=>this.stop(state)).finally(()=>{state.checking=false}).catch(()=>{})
+    }},5000);this.timer.unref()
+  }
+  private guard(state:Manual){if(!state.valid||this.manual.get(state.meta.environmentId)!==state)throw new HttpError(410,'电脑控制权已结束','computer_control_expired')}
+  private state(meta:ComputerTarget){const state=this.manual.get(meta.environmentId);if(state&&state.meta.ownerKey!==meta.ownerKey)throw new HttpError(403,'电脑归属不匹配','computer_owner_mismatch');return state}
+  async status(meta:ComputerTarget):Promise<ComputerControlStatus>{
+    const state=this.state(meta)
+    if(state)return {mode:state.mode,controlId:state.id,generation:state.lease?.generation,canResume:!!state.gateway,error:state.error}
+    const resource=this.runtime.pool.status(meta.ownerKey).find(item=>item.environmentId===meta.environmentId)
+    return {mode:resource?.status==='active'?'agent':resource?.status==='idle'?'idle':'off',generation:resource?.generation}
+  }
+  async frame(meta:ComputerTarget,authorize:()=>void):Promise<ComputerFrame>{
+    this.runtime.assertTarget(meta);authorize()
+    const spec=this.runtime.pool.definition(meta.ownerKey,meta.environmentId)
+    if(!spec)throw new HttpError(409,'电脑尚未启动','computer_not_running')
+    const status=await this.status(meta),cached=this.frames.get(meta.environmentId)?.at(-1)
+    if(cached&&cached.generation===status.generation&&Date.now()-cached.capturedAt<700)return cached
+    const existing=this.captures.get(meta.environmentId);if(existing)return existing.then(frame=>{authorize();return frame})
+    const pending=this.runtime.provider.capture(spec,authorize).then(frame=>{
+      authorize();const result:ComputerFrame={...frame,id:randomUUID(),generation:status.generation??0}
+      const history=(this.frames.get(meta.environmentId)??[]).filter(frame=>Date.now()-frame.capturedAt<10000).slice(-4)
+      this.frames.set(meta.environmentId,[...history,result]);return result
+    }).finally(()=>this.captures.delete(meta.environmentId))
+    this.captures.set(meta.environmentId,pending);return pending
+  }
+  async take(meta:ComputerTarget,profile:string,id:string,check:()=>Promise<void>):Promise<ComputerControlStatus>{
+    this.runtime.assertTarget(meta);await check()
+    const old=this.state(meta)
+    if(old){if(old.id===id)return this.status(meta);throw new HttpError(409,'电脑已由另一控制会话接管','computer_control_busy')}
+    const gateway=this.runtime.gateways.get(meta.environmentId)
+    const state:Manual={id,meta,profile,mode:'pausing',valid:true,gateway:gateway?.active?gateway:undefined,check,checking:false,actions:new Map()}
+    this.manual.set(meta.environmentId,state)
+    state.pending=(async()=>{
+      if(state.gateway){state.lease=await state.gateway.takeControl(id,()=>this.guard(state));state.spec=state.gateway.specification}
+      else{
+        const resolved=await this.runtime.resolveWorkspace(profile);await check();this.guard(state)
+        state.spec={id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.runtime.config.imageId,cwd:resolved.cwd,network:this.runtime.config.network??'none'}
+        await this.runtime.pool.configure(state.spec,()=>this.guard(state))
+        state.lease=await this.runtime.pool.acquire(state.spec,`human:${id}`,()=>this.guard(state))
+        if(this.runtime.config.network==='public-proxy')state.proxy=await ComputerPublicProxy.start(this.runtime.provider,state.spec,this.runtime.proxyScript,()=>this.runtime.pool.authorize(state.lease!),check,()=>{void this.stop(state)})
+      }
+      await check();this.guard(state);state.mode='human'
+    })().catch(async()=>{if(!state.valid)return;state.mode='error';state.error='接管未完成,请等待当前操作结束后重试';await this.stop(state).catch(()=>{})})
+    return this.status(meta)
+  }
+  async input(meta:ComputerTarget,id:string,requestId:string,generation:number,frameId:string,value:unknown):Promise<unknown>{
+    const state=this.state(meta)
+    if(!state||state.id!==id||state.mode!=='human'||!state.lease||!state.spec)throw new HttpError(403,'需要当前电脑的控制权','computer_control_required')
+    this.guard(state);await state.check()
+    if(state.lease.generation!==generation)throw new HttpError(409,'控制版本已变化','computer_control_stale')
+    const frame=this.frames.get(meta.environmentId)?.find(frame=>frame.id===frameId&&frame.generation===generation&&Date.now()-frame.capturedAt<10000)
+    if(!frame)throw new HttpError(409,'画面已过期,请刷新后操作','computer_frame_stale')
+    const action=actionSchema.parse(value),fingerprint=createHash('sha256').update(JSON.stringify({action,generation,frameId})).digest('hex')
+    const old=state.actions.get(requestId)
+    if(old){if(old.fingerprint!==fingerprint)throw new HttpError(409,'操作编号与内容不一致','idempotency_conflict');return old.result}
+    if(state.actions.size>=1000)throw new HttpError(429,'本次接管操作已达上限,请重新接管','computer_control_limit')
+    const common={scope:'desktop',session:`yaoyao-control-${state.id}`}
+    let tool:string,args:Record<string,unknown>,argv:string[]|undefined
+    if(action.kind==='click'){
+      if(action.x>=frame.width||action.y>=frame.height)throw new HttpError(400,'点击位置超出画面','computer_point_invalid')
+      tool='click';args={...common,x:action.x,y:action.y,button:action.button,count:action.count}
+    }else if(action.kind==='drag'){
+      if(action.fromX>=frame.width||action.toX>=frame.width||action.fromY>=frame.height||action.toY>=frame.height)throw new HttpError(400,'拖动位置超出画面','computer_point_invalid')
+      tool='drag';args={}
+      argv=['env','DISPLAY=:1','xdotool','mousemove','--sync',String(action.fromX),String(action.fromY),'mousedown','1','mousemove','--sync',String(action.toX),String(action.toY),'mouseup','1']
+    }else if(action.kind==='text'){tool='type_text';args={...common,text:action.text}}
+    else if(action.kind==='key'){
+      const key=({Return:'enter',BackSpace:'backspace',Escape:'esc'} as Record<string,string>)[action.key]??action.key.toLowerCase()
+      tool=action.modifiers.length?'hotkey':'press_key';args=action.modifiers.length?{...common,keys:[...action.modifiers,key]}:{...common,key}
+    }else {tool='scroll';args={...common,x:Math.floor(frame.width/2),y:Math.floor(frame.height/2),by:'line',direction:action.direction,amount:action.amount}}
+    const result=this.runtime.pool.use(state.lease,async context=>{
+      this.guard(state);await state.check()
+      const response=await this.runtime.provider.execute(state.spec!,argv??[CUA_DRIVER,'call',tool,JSON.stringify(args),'--socket',CUA_SOCKET],context)
+      this.guard(state);let parsed:any;try{parsed=JSON.parse(response.stdout)}catch{}
+      if(parsed?.code||parsed?.error||parsed?.isError)throw new HttpError(502,'电脑未接受这次输入,请刷新画面后重试','computer_action_failed')
+      return {ok:true,result:response.stdout}
+    })
+    state.actions.set(requestId,{fingerprint,result});return result
+  }
+  async giveBack(meta:ComputerTarget,id:string,notes:string){
+    const state=this.state(meta);if(!state||state.id!==id)throw new HttpError(410,'电脑控制权已结束','computer_control_expired')
+    await state.pending;this.guard(state);await state.check()
+    if(state.gateway){state.mode='resuming';await state.gateway.giveBack(notes);state.valid=false;this.manual.delete(meta.environmentId)}
+    else if(this.runtime.retainDesktops&&state.lease){
+      await state.proxy?.close();await this.runtime.pool.release(state.lease,true)
+      state.valid=false;this.manual.delete(meta.environmentId)
+    }else await this.stop(state)
+    return {ok:true}
+  }
+  private async stop(state:Manual){
+    if(state.stopping)return state.stopping
+    state.valid=false
+    state.stopping=(async()=>{
+      await state.proxy?.close()
+      if(state.gateway)await state.gateway.stopControl()
+      else if(state.lease)await this.runtime.pool.release(state.lease).catch(error=>{if(error.code!=='computer_lease_stale')throw error})
+      if(this.manual.get(state.meta.environmentId)===state)this.manual.delete(state.meta.environmentId)
+    })().catch(error=>{state.mode='error';state.error='电脑停止状态待确认';state.stopping=undefined;throw error})
+    return state.stopping
+  }
+  async close(){clearInterval(this.timer);await Promise.allSettled([...this.manual.values()].map(state=>this.stop(state)))}
+}

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

@@ -0,0 +1,320 @@
+import {randomUUID} from 'node:crypto'
+import {join,dirname,posix} from 'node:path'
+import {fileURLToPath} from 'node:url'
+import {mkdir,rm} from 'node:fs/promises'
+import {existsSync} from 'node:fs'
+import type {DatabaseSync} from 'node:sqlite'
+import {z} from 'zod'
+import {HttpError} from '../../server/errors.js'
+import type {GatewayFrame} from '../../server/workspaceGateway.js'
+import {UNCONFIGURED_COMPUTER_IMAGE} from '../../shared/runner.js'
+import type {RunnerConfiguration} from '../../shared/runner.js'
+import {ContainerComputerProvider,COMPUTER_WORKSPACE,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
+import {ComputerPool,type ComputerLease} from '../computers/pool.js'
+import {ComputerControls} from './control.js'
+import {ComputerPublicProxy} from '../network/computerProxy.js'
+import {HermesWorkerProcess,type WorkerModel,type WorkerTool,type WorkerFrame} from './process.js'
+
+export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string}
+export interface WorkerSession {agentId?:string;id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain'}
+interface Live {workMarker?:string;paused?:boolean;pauseJob?:Promise<ComputerLease>;segment?:number;toolCalls:Set<Promise<unknown>>;journal:Map<string,{name:string;result:unknown}>;proxy?:ComputerPublicProxy;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 {
+  retainDesktops=false
+  readonly controls:ComputerControls
+  readonly provider:ContainerComputerProvider
+  readonly pool:ComputerPool
+  readonly ready:Promise<void>
+  readonly config:NonNullable<RunnerConfiguration['computers']>
+  readonly gateways=new Map<string,ComputerGateway>()
+  readonly workers=new Map<string,{environmentId:string;ownerKey:string;process:HermesWorkerProcess}>()
+  readonly script:string
+  readonly proxyScript: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.proxyScript=existsSync(join(dirname(this.script),'guest_proxy.py'))?join(dirname(this.script),'guest_proxy.py'):join(dirname(this.script),'../network/guest_proxy.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 retired_computers(id TEXT PRIMARY KEY,owner_key TEXT NOT NULL,spec TEXT)')
+    db.exec('CREATE TABLE IF NOT EXISTS computer_sessions(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
+    this.controls=new ComputerControls(this)
+    this.ready=this.pool.recover();void this.ready.catch(()=>{})
+  }
+  assertTarget(meta:ComputerTarget){if(this.db.prepare('SELECT id FROM retired_computers WHERE id=?').get(meta.environmentId))throw new HttpError(410,'临时电脑已退役','computer_retired')}
+  async retire(meta:ComputerTarget){
+    const old=this.db.prepare('SELECT owner_key,spec FROM retired_computers WHERE id=?').get(meta.environmentId) as {owner_key:string;spec?:string}|undefined
+    if(old&&old.owner_key!==meta.ownerKey)throw new HttpError(403,'电脑归属不匹配','computer_owner_mismatch')
+    this.pool.definition(meta.ownerKey,meta.environmentId)
+    this.db.prepare('INSERT OR IGNORE INTO retired_computers VALUES(?,?,NULL)').run(meta.environmentId,meta.ownerKey)
+    await Promise.all([...this.workers.values()].filter(worker=>worker.environmentId===meta.environmentId&&worker.ownerKey===meta.ownerKey).map(worker=>worker.process.close()))
+    const current=this.pool.status(meta.ownerKey).find(row=>row.environmentId===meta.environmentId)
+    if(current?.holderId)await this.pool.stopHolder(meta.ownerKey,meta.environmentId,current.holderId)
+    let spec=old?.spec?JSON.parse(old.spec):undefined
+    spec??=this.pool.definition(meta.ownerKey,meta.environmentId)
+    if(spec)this.db.prepare('UPDATE retired_computers SET spec=? WHERE id=?').run(JSON.stringify(spec),meta.environmentId)
+    await this.pool.retire(meta.ownerKey,meta.environmentId)
+    if(spec)await this.provider.deleteWorkspace(spec)
+    const sessions=(this.db.prepare('SELECT id,value FROM computer_sessions').all() as {id:string;value:string}[]).filter(row=>{const session=JSON.parse(row.value);return session.environmentId===meta.environmentId&&session.ownerKey===meta.ownerKey})
+    for(const row of sessions){if(!/^[0-9a-f-]{36}$/.test(row.id))throw new Error('电脑会话编号无效');await rm(join(this.home,'workers',row.id),{recursive:true,force:true});this.db.prepare('DELETE FROM computer_sessions WHERE id=?').run(row.id)}
+    this.db.prepare('UPDATE retired_computers SET spec=NULL WHERE id=?').run(meta.environmentId)
+  }
+  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.agentId??session.environmentId)!==meta.agentId||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){
+    return this.resolveProfile(profile,'resolve',signal)
+  }
+  async resolveWorkspace(profile:string,signal?:AbortSignal){
+    return this.resolveProfile(profile,'resolve-workspace',signal)
+  }
+  private async resolveProfile(profile:string,mode:'resolve'|'resolve-workspace',signal?:AbortSignal){
+    const resolver=new HermesWorkerProcess(this.config.python,this.script,{mode,profile,defaultCwd:COMPUTER_WORKSPACE,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()}
+  }
+  async desktop(meta:ComputerTarget,profile:string,action:'create'|'start'|'stop'|'recreate'|'remove',authorize:()=>void){
+    this.assertTarget(meta);authorize()
+    // Stopping/removing an existing desktop must remain possible even when
+    // its Profile or model was removed or is temporarily misconfigured.
+    if(action==='stop'||action==='remove'){
+      const spec=this.pool.definition(meta.ownerKey,meta.environmentId)
+      if(spec)await this.pool.desktop(spec,action,authorize)
+      return
+    }
+    const resolved=await this.resolveWorkspace(profile);authorize()
+    await this.pool.desktop({id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.config.imageId,cwd:resolved.cwd,network:this.config.network??'none'},action,authorize)
+  }
+}
+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(){this.runtime.assertTarget(this.meta);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,network:this.runtime.config.network??'none'}}
+  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}}}
+      if(this.runtime.config.imageId===UNCONFIGURED_COMPUTER_IMAGE)throw new HttpError(409,'请先在应用设置的本地虚拟机页面完成准备','computer_image_required')
+      const profile=String(params.profile),resolved=await this.runtime.resolve(profile,this.controller.signal)
+      await this.authorized();this.guard()
+      const session:WorkerSession=method==='session.resume'?this.runtime.session(String(params.session_id),this.meta,profile):{id:randomUUID(),agentId:this.meta.agentId,ownerKey:this.meta.ownerKey,environmentId:this.meta.environmentId,profile,cwd:resolved.cwd,configuredCwd:resolved.configuredCwd,history:[]}
+      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,toolCalls:new Set(),journal:new Map()}
+      this.runtime.save(session);this.runtime.gateways.set(this.meta.environmentId,this)
+      if(this.runtime.config.network==='public-proxy'){
+        const live=this.live
+        try{live.proxy=await ComputerPublicProxy.start(this.runtime.provider,this.spec(session),this.runtime.proxyScript,()=>this.runtime.pool.authorize(live.lease),()=>this.authorized(),()=>{void this.interrupt().finally(()=>{if(!this.closed)this.onDisconnect()}).catch(()=>{})})}
+        catch(error){await this.interrupt();throw error}
+      }
+      this.timer=setInterval(()=>{if(this.checking)return;this.checking=true;void this.authorized().then(()=>this.runtime.pool.renew(this.live!.lease)).catch(async()=>{await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()}).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()
+      if(typeof params.workMarker==='string'&&/^\[yaoyao-run:[0-9a-f-]{36}:[0-9a-f-]{36}\]$/.test(params.workMarker))live.workMarker=params.workMarker
+      await this.startModel(live,String(params.text??''),structuredClone(live.session.history))
+      return {status:'streaming'}
+    }
+    throw new HttpError(409,'当前隔离 Worker 不支持此交互','computer_method_unavailable')
+  }
+  private async startModel(live:Live,prompt:string,history:any[]){
+      live.received=false;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,String(live.segment=(live.segment??0)+1))
+      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,network:this.runtime.config.network??'none',tools:[...TOOLS,...(this.team?.catalog??[])],prompt:live.images.length?[{type:'text',text:prompt},...live.images]:prompt,history})
+      live.worker=worker
+      this.runtime.workers.set(this.workId,{environmentId:this.meta.environmentId,ownerKey:this.meta.ownerKey,process:worker});void worker.exited.then(()=>{if(this.runtime.workers.get(this.workId)?.process===worker)this.runtime.workers.delete(this.workId)})
+      worker.onTool=(name,args,id,callId)=>{
+        const task=this.tool(live,name,args,id).then(result=>{if(callId)live.journal.set(callId,{name,result});return result},error=>{if(callId)live.journal.set(callId,{name,result:{error:'操作未完成或已中断'}});throw error})
+        live.toolCalls.add(task);void task.finally(()=>live.toolCalls.delete(task)).catch(()=>{});return task
+      }
+      worker.onEvent=frame=>{
+        if(frame.type==='checkpoint'&&Array.isArray(frame.messages)){live.session.history=frame.messages;this.runtime.save(live.session);return}
+        if(live.paused)return
+        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.paused&&!live.received&&live.running&&!this.closed)void this.lost(live)})
+  }
+  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>{
+    if(live.paused)throw new HttpError(409,'电脑正在等待人工操作','computer_paused')
+    await this.authorized();this.guard()
+    if(live.paused)throw new HttpError(409,'电脑正在等待人工操作','computer_paused')
+    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.paused||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,success&&this.runtime.retainDesktops)
+    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,keepRunning=false){clearInterval(this.timer);live.releasing??=(async()=>{await live.proxy?.close();await this.runtime.pool.release(live.lease,keepRunning)})();return live.releasing}
+  private async lost(live:Live){
+    if(!live.running||live.paused||this.closed||live.controller.signal.aborted)return
+    live.running=false;await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()
+  }
+  get active(){return !!this.live?.running&&!this.live.received&&!this.closed}
+  get paused(){return this.live?.paused===true}
+  get lease(){return this.live?.lease}
+  get specification(){return this.live?this.spec(this.live.session):undefined}
+  async takeControl(controlId:string,authorize:()=>void):Promise<ComputerLease>{
+    const live=this.live
+    if(!live||!this.active)throw new HttpError(409,'Agent 已不在执行,请重新打开电脑','computer_not_active')
+    if(live.pauseJob)return live.pauseJob
+    live.paused=true;this.event('computer.paused',{stage:'pausing'})
+    live.pauseJob=(async()=>{
+      await live.worker?.close()
+      await Promise.allSettled([...live.toolCalls])
+      authorize();this.guard()
+      const history=structuredClone(live.session.history),done=new Set(history.filter(message=>message.role==='tool').map(message=>message.tool_call_id))
+      for(const message of [...history])for(const tool of message.tool_calls??[])if(!done.has(tool.id)){
+        const entry=live.journal.get(tool.id),value=entry?.result??{error:'用户接管时此操作未完成,继续前请检查电脑状态,不要盲目重复'}
+        const content=typeof value==='string'?value:JSON.stringify(value)
+        history.push({role:'tool',name:tool.function?.name??entry?.name,tool_call_id:tool.id,content});done.add(tool.id)
+      }
+      live.session.history=history;this.runtime.save(live.session)
+      live.lease=await this.runtime.pool.transfer(live.lease,`human:${controlId}`,authorize)
+      this.event('computer.paused',{stage:'human',generation:live.lease.generation})
+      return live.lease
+    })()
+    return live.pauseJob
+  }
+  async giveBack(notes:string):Promise<void>{
+    const live=this.live
+    if(!live?.paused)throw new HttpError(409,'Agent 没有处于接管状态','computer_not_paused')
+    await live.pauseJob;await this.authorized()
+    live.lease=await this.runtime.pool.transfer(live.lease,this.workId,()=>this.guard())
+    live.paused=false;live.pauseJob=undefined;live.journal.clear()
+    this.event('computer.resumed',{generation:live.lease.generation,notes})
+    await this.startModel(live,`用户已完成电脑接管并明确交还控制。请先检查当前电脑状态,再继续原任务;不要重复已完成的副作用。用户说明:${notes||'无额外说明'}\n${live.workMarker??''}`,structuredClone(live.session.history))
+  }
+  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
+  }
+  async stopControl(){await this.interrupt();this.event('message.complete',{text:'电脑控制已结束,本轮已停止',status:'interrupted'})}
+  close(){if(this.runtime.gateways.get(this.meta.environmentId)===this)this.runtime.gateways.delete(this.meta.environmentId);this.closed=true;this.controller.abort();this.team=undefined;clearInterval(this.timer);return this.interrupt()}
+}

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

@@ -0,0 +1,207 @@
+"""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()
+
+class ConfigurationError(Exception):
+    def __init__(self, code):
+        self.code = code
+        super().__init__(code)
+
+
+def load_profile():
+    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 ConfigurationError("computer_profile_invalid")
+    home = root if profile == "default" else root / "profiles" / profile
+    if not (home / "config.yaml").is_file():
+        raise ConfigurationError("computer_profile_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
+    try:
+        return load_config_readonly()
+    except Exception:
+        raise ConfigurationError("computer_profile_config_invalid") from None
+
+
+def resolve_workspace(config):
+    terminal = config.get("terminal", {})
+    if not isinstance(terminal, dict):
+        raise ConfigurationError("computer_cwd_invalid")
+    raw = terminal.get("cwd")
+    if raw is None or raw == "":
+        raw = "."
+    if not isinstance(raw, str) or not raw.strip() or len(raw) > 4096 or re.search(r"[\x00-\x1f\x7f]", raw):
+        raise ConfigurationError("computer_cwd_invalid")
+    configured = raw.strip()
+    # Placeholders refer to the assigned Linux workspace, never the Web/App
+    # release directory inherited by this host-side configuration reader.
+    base = BOOT.get("defaultCwd", "/home/cua/workspace")
+    path = "." if configured in (".", "auto", "cwd") else os.path.expanduser(configured)
+    cwd = os.path.normpath(path if os.path.isabs(path) else os.path.join(base, path))
+    if not os.path.isabs(path) and os.path.commonpath([base, cwd]) != base:
+        raise ConfigurationError("computer_cwd_invalid")
+    return {"cwd": cwd, "configuredCwd": configured}
+
+
+def resolve_model():
+    config = load_profile()
+    workspace = resolve_workspace(config)
+    model = config.get("model", {})
+    if not isinstance(model, dict) or not model.get("default"):
+        raise ConfigurationError("computer_model_missing")
+    try:
+        from hermes_cli.runtime_provider import resolve_runtime_provider
+        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"])
+    except Exception:
+        raise ConfigurationError("computer_model_unavailable") from None
+    if runtime.get("api_mode") not in ("chat_completions", "anthropic_messages", "codex_responses"):
+        raise ConfigurationError("computer_model_unsupported")
+    emit("resolved", model={key: runtime[key] for key in ("provider", "api_mode", "base_url", "api_key") if key in runtime} | {"model": model["default"]}, **workspace)
+
+
+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
+            emit("checkpoint", messages=messages)
+            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))
+                emit("checkpoint", messages=messages)
+
+        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, callId=tool_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. Network access follows the configured computer policy. 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-workspace":
+            emit("resolved", **resolve_workspace(load_profile()))
+        elif 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", code=error.code if isinstance(error, ConfigurationError) else "computer_worker_failed")
+    sys.exit(1)

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

@@ -0,0 +1,71 @@
+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}
+const configurationErrors:Record<string,string>={
+  computer_profile_invalid:'基础 Profile 名称无效',
+  computer_profile_missing:'未找到基础 Profile 配置,请检查执行节点上的 Hermes Profile',
+  computer_profile_config_invalid:'基础 Profile 配置无法读取,请检查 config.yaml',
+  computer_cwd_invalid:'基础 Profile 的 terminal.cwd 无效,请使用工作区内的相对路径或有效绝对路径',
+  computer_model_missing:'基础 Profile 尚未配置模型,请先选择模型后再运行 Agent',
+  computer_model_unavailable:'基础 Profile 的模型配置无法使用,请检查 Provider 与认证信息',
+  computer_model_unsupported:'基础 Profile 的模型接口暂不支持隔离电脑 Agent',
+}
+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,callId?: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'){
+      const code=typeof frame.code==='string'&&Object.hasOwn(configurationErrors,frame.code)?frame.code:'computer_worker_failed'
+      this.fail(new HttpError(502,configurationErrors[code]??'Hermes Worker 执行失败,请核对基础 Profile 与模型连接',code))
+    }
+    if(frame.type==='tool'){
+      const work=Promise.resolve().then(()=>this.onTool(String(frame.name),frame.arguments,String(frame.id),typeof frame.callId==='string'?frame.callId:undefined)).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
+  }
+}

+ 33 - 4
src/server/app.ts

@@ -49,6 +49,10 @@ import {
   HermesChatNotificationResolver,
 } from './pushEvents.js'
 import { ChatCacheCoordinator, ChatCacheStore } from './chatCache.js'
+import {LocalVmService} from './localVm.js'
+import {SharedComputers} from './sharedComputers.js'
+import {ComputerControlService} from './computerControls.js'
+import { RunnerHub } from './runnerHub.js'
 
 export interface ApplicationOptions {
   config?: ServerConfig
@@ -69,6 +73,7 @@ export interface ApplicationOptions {
 }
 
 export interface ApplicationRuntime {
+  runners: RunnerHub
   workspace: WorkspaceStore
   workspaceRuntime: WorkspaceRuntime
   realtime: RealtimeAPI
@@ -90,6 +95,7 @@ export interface ApplicationRuntime {
   pushEventCoordinator: PushCoordinatorEventAdapter
   chatPushJobs: ChatPushJobManager
   chatCache?: ChatCacheCoordinator
+  localVm: LocalVmService
   close(): void
 }
 
@@ -205,8 +211,20 @@ 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 workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner))
+  const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
+  const sharedComputers=new SharedComputers(workspace,auth,workspaceNodes,runners)
+  const computerControls=new ComputerControlService(workspace,auth,workspaceNodes,runners)
+  const localVm=new LocalVmService(workspace,auth,workspaceNodes,runners,sharedComputers,config)
+  runners.localVmAllowed=(id,runnerId)=>localVm.allowed(id,runnerId)
+  runners.controlAllowed=(id,runnerId)=>computerControls.allowed(id,runnerId)
+  workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
+  const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0)
+  workspaceRuntime.retireHelper=(owner,helper)=>runners.retireHelper(owner,helper)
+  workspaceRuntime.onTeamCreated = (owner, team) => {
+    try { push.setGroupSubscription(owner, team.id, true, team.lastSeq) } catch { /* Optional notifications do not undo a team. */ }
+  }
   const workspaceAssets = new WorkspaceAssets(workspace, workspaceNodes, config.home)
+  workspaceRuntime.publishArtifact=(owner,message,agent,name,bytes)=>workspaceAssets.publish(owner,message,agent,name,bytes)
   workspaceRuntime.onMessage = (owner, message) => workspaceAssets.archive(owner, message)
   workspaceRuntime.onNotify = (owner, c, run, message, interaction) => {
     if (c.kind === 'group' && !push.isGroupSubscribed(owner,c.id)) return
@@ -288,13 +306,14 @@ 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/'))) {
       const path = ctx.path
       const publicPath = ['/api/status', '/api/auth/providers', '/api/auth/me', '/api/profiles', '/api/app/bootstrap', '/api/app/login', '/api/app/logout'].includes(path)
       const accountPath = ['/api/account/credentials', '/api/app/account/credentials', '/api/app/account/avatar'].includes(path)
-      const workspacePath = /^\/api\/app\/(?:capabilities|agents|conversations|runs|interactions|events|uploads|files|message-files)(?:\/|$)/.test(path)
+      const workspacePath = /^\/api\/app\/(?:capabilities|agents|conversations|runs|interactions|events|uploads|files|message-files|computers)(?:\/|$)/.test(path)
         && path !== '/api/app/agents/remote'
       const pushPath = /^\/api\/(?:app\/)?push\/v1\//.test(path)
       const identityRead = path === '/api/app/server-identity' && ['GET', 'HEAD'].includes(ctx.method)
@@ -362,7 +381,11 @@ export function createApplication(options: ApplicationOptions = {}): Application
     } else await next()
   })
 
+  const sharedComputerRouter=sharedComputers.router();app.use(sharedComputerRouter.routes());app.use(sharedComputerRouter.allowedMethods())
+  const localVmRouter=localVm.router();app.use(localVmRouter.routes());app.use(localVmRouter.allowedMethods())
+  const computerRouter=computerControls.router();app.use(computerRouter.routes());app.use(computerRouter.allowedMethods())
   const applicationRouter = workspaceRouter(workspace, workspaceRuntime, workspaceNodes, workspaceAssets, uploads, auth, push)
+  app.use(runners.adminRouter().routes())
   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')
@@ -419,6 +442,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
   })
 
   return {
+    runners,
+    localVm,
     workspace,
     workspaceRuntime,
     realtime,
@@ -441,6 +466,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     chatPushJobs,
     chatCache,
     close: () => {
+      runners.close()
       workspaceAssets.close()
       workspaceRuntime.close()
       workspaceNodes.close()
@@ -457,7 +483,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
 
 export interface NodeServerRuntime {
   server: HttpServer
-  close(): Promise<void>
+  close(closeConnections?: boolean): Promise<void>
 }
 
 export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime {
@@ -475,16 +501,19 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
       runtime.chatPushJobs.stop()
     }
   }
+  server.once('listening',()=>{const address=server.address();if(address&&typeof address!=='string')void runtime.localVm.start(`${config.tlsCert?'https':'http'}://127.0.0.1:${address.port}`).catch(error=>console.error('本地虚拟机服务启动失败',error.message))})
   runtime.workspaceRuntime.start()
   synchronizePushObservers()
   const removePushConfigurationListener = runtime.push.onEnabledChange(synchronizePushObservers)
   const removeWebSockets = runtime.realtime.rejectLegacyUpgrades(server)
   return {
     server,
-    close: async () => {
+    close: async (closeConnections = false) => {
+      await runtime.localVm.close()
       removePushConfigurationListener()
       removeWebSockets()
       runtime.realtime.close()
+      if (closeConnections) server.closeAllConnections()
       await new Promise<void>((resolve, reject) => {
         if (!server.listening) {
           resolve()

+ 13 - 0
src/server/buildIdentity.ts

@@ -0,0 +1,13 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+export interface BuildIdentity { commit: string; buildNumber: number; dirty: boolean; artifactDigest?: string }
+export function readBuildIdentity(root: string): BuildIdentity | undefined {
+  try {
+    const build = JSON.parse(readFileSync(join(root, 'build-info.json'), 'utf8'))
+    if (typeof build.commit !== 'string' || !Number.isSafeInteger(build.buildNumber)) return undefined
+    let artifactDigest: string | undefined
+    try { artifactDigest = JSON.parse(readFileSync(join(root, 'runtime-manifest.json'), 'utf8')).artifactDigest } catch { /* Source deployment. */ }
+    return { commit: build.commit, buildNumber: build.buildNumber, dirty: build.dirty === true, artifactDigest }
+  } catch { return undefined }
+}

+ 2 - 1
src/server/chatCache.ts

@@ -522,7 +522,8 @@ export class ChatCacheStore {
       for (const table of ['chat_messages', 'chat_events', 'chat_attachments', 'chat_sessions']) {
         this.db.prepare(`DELETE FROM ${table} WHERE owner=? AND profile=? AND session_id=?`).run(owner, profile, sessionID)
       }
-      this.db.prepare("DELETE FROM chat_snapshots WHERE owner=? AND (session_id=? OR kind='list')").run(owner, sessionID)
+      this.db.prepare("DELETE FROM chat_snapshots WHERE owner=? AND ((profile=? AND session_id=?) OR kind='list')")
+        .run(owner, profile, sessionID)
       this.db.exec('COMMIT')
       for (const asset of assets) {
         const referenced = this.db.prepare('SELECT 1 ok FROM chat_attachments WHERE local_path=? LIMIT 1').get(asset.local_path)

+ 77 - 0
src/server/computerControls.ts

@@ -0,0 +1,77 @@
+import {randomBytes,randomUUID,createHash,timingSafeEqual} from 'node:crypto'
+import Router from '@koa/router'
+import {z} from 'zod'
+import {HttpError} from './errors.js'
+import {parse,type WorkspaceStore} from './workspaceStore.js'
+import type {LocalAuthStore} from './localAuth.js'
+import type {WorkspaceAgent} from '../shared/workspace.js'
+import type {WorkspaceNodes} from './workspaceGateway.js'
+import type {RunnerHub} from './runnerHub.js'
+const hash=(value:string)=>createHash('sha256').update(value).digest()
+interface Grant {environmentId?:string;id:string;agentId:string;owner:string;requestId:string;runnerId:string;authVersion:number;expiresAt:number;tokenHash:string;sealedToken:string}
+export class ComputerControlService {
+  constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly hub:RunnerHub){}
+  private agent(owner:string,id:string){
+    const agent=this.store.require<WorkspaceAgent>(owner,'agent',id)
+    if(agent.archived||agent.execution!=='computer'||agent.remoteAgentId)throw new HttpError(409,'该 Agent 没有可用的隔离电脑','computer_unavailable')
+    this.nodes.requireSource(owner,agent);return agent
+  }
+  allowed(id:string,runnerId:string):boolean{
+    const grant=this.store.get<Grant>('_system','computer-control',id)
+    if(!grant||grant.runnerId!==runnerId||grant.expiresAt<=Date.now()||this.auth.pushAuthorizationVersion(grant.owner)!==grant.authVersion)return false
+    try{const agent=this.agent(grant.owner,grant.agentId);return (agent.computerEnvironmentId??agent.id)===(grant.environmentId??grant.agentId)}catch{return false}
+  }
+  private token(owner:string,agentId:string,body:unknown){
+    const input=parse(z.object({controlId:z.string().uuid(),token:z.string().min(32).max(256)}).passthrough(),body)
+    const grant=this.store.get<Grant>('_system','computer-control',input.controlId)
+    if(!grant||grant.owner!==owner||grant.agentId!==agentId||!timingSafeEqual(Buffer.from(grant.tokenHash,'hex'),hash(input.token)))throw new HttpError(403,'需要当前电脑的控制凭据','computer_control_forbidden')
+    if(!this.allowed(grant.id,grant.runnerId))throw new HttpError(410,'电脑控制权已过期','computer_control_expired')
+    return grant
+  }
+  router(){
+    const router=new Router()
+    router.get('/api/app/computers',ctx=>{
+      const actor=this.auth.require(ctx),agents=this.store.list<WorkspaceAgent>(actor.id,'agent').filter(agent=>!agent.archived)
+      ctx.body={canManageLocalVm:actor.role==='admin',computers:agents.map(agent=>{
+        let available=false,reason='在 Agent 设置中将执行环境设为隔离电脑'
+        if(agent.execution==='computer')try{this.nodes.requireSource(actor.id,agent);const record=this.hub.computerRunner(actor.id,agent),features=this.hub.summary(record).features;if(features.includes('local-vm-v1')&&!features.includes('image-ready-v1'))throw new Error('请先在应用设置的本地虚拟机页面完成准备');available=true;reason='可以打开电脑面板'}catch(error){reason=error instanceof Error?error.message:'电脑不可用'}
+        return {agent,available,reason}
+      })}
+    })
+    router.get('/api/app/agents/:id/computer',async ctx=>{
+      const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id)
+      ctx.body=await this.hub.computer(owner,agent,'status',{},()=>{this.agent(owner,agent.id)})
+    })
+    router.get('/api/app/agents/:id/computer/frame',async ctx=>{
+      const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id)
+      ctx.set('Cache-Control','no-store');ctx.body=await this.hub.computer(owner,agent,'frame',{},()=>{this.agent(owner,agent.id)})
+    })
+    router.post('/api/app/agents/:id/computer/take',async ctx=>{
+      const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),input=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body)
+      const old=this.store.list<Grant>('_system','computer-control').find(grant=>grant.owner===owner&&(grant.environmentId??grant.agentId)===(agent.computerEnvironmentId??agent.id)&&grant.expiresAt>Date.now()&&this.allowed(grant.id,grant.runnerId))
+      if(old&&(old.agentId!==agent.id||old.requestId!==input.requestId))throw new HttpError(409,'电脑已由另一个页面控制,关闭原页面或等待控制权到期','computer_control_busy')
+      const runnerId=this.hub.computerRunner(owner,agent).id
+      const token=old?this.nodes.open<string>(old.sealedToken):randomBytes(32).toString('base64url')
+      const grant:Grant=old??{environmentId:agent.computerEnvironmentId??agent.id,id:randomUUID(),requestId:input.requestId,owner,agentId:agent.id,runnerId,authVersion:this.auth.pushAuthorizationVersion(owner)??0,expiresAt:Date.now()+30000,tokenHash:hash(token).toString('hex'),sealedToken:this.nodes.seal(token)}
+      this.store.put('_system','computer-control',grant.id,grant)
+      const status=await this.hub.computer(owner,agent,'take',{controlId:grant.id},()=>{if(!this.allowed(grant.id,runnerId))throw new HttpError(403,'电脑控制请求已失效','computer_control_expired')})
+      ctx.body={...status,controlId:grant.id,token,expiresAt:grant.expiresAt}
+    })
+    router.post('/api/app/agents/:id/computer/renew',ctx=>{
+      const owner=this.auth.require(ctx).id,grant=this.token(owner,ctx.params.id,(ctx.request as any).body)
+      grant.expiresAt=Date.now()+30000;this.store.put('_system','computer-control',grant.id,grant);ctx.body={expiresAt:grant.expiresAt}
+    })
+    router.post('/api/app/agents/:id/computer/input',async ctx=>{
+      const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),input=parse(z.object({controlId:z.string().uuid(),token:z.string(),requestId:z.string().uuid(),generation:z.number().int().nonnegative(),frameId:z.string().uuid(),action:z.record(z.string(),z.unknown())}).strict(),(ctx.request as any).body)
+      const grant=this.token(owner,agent.id,input)
+      ctx.body=await this.hub.computer(owner,agent,'input',{controlId:grant.id,requestId:input.requestId,generation:input.generation,frameId:input.frameId,action:input.action},()=>{this.token(owner,agent.id,input)})
+    })
+    router.post('/api/app/agents/:id/computer/giveback',async ctx=>{
+      const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),input=parse(z.object({controlId:z.string().uuid(),token:z.string(),notes:z.string().max(4000).default('')}).strict(),(ctx.request as any).body)
+      const grant=this.token(owner,agent.id,input)
+      ctx.body=await this.hub.computer(owner,agent,'giveback',{controlId:grant.id,notes:input.notes},()=>{this.token(owner,agent.id,input)})
+      grant.expiresAt=0;this.store.put('_system','computer-control',grant.id,grant)
+    })
+    return router
+  }
+}

+ 38 - 3
src/server/dashboardSupervisor.ts

@@ -1,4 +1,4 @@
-import { execFileSync, spawn } from 'node:child_process'
+import { execFileSync, spawn, type ChildProcess } from 'node:child_process'
 import { createConnection } from 'node:net'
 
 const DASHBOARD_PORT = 9119
@@ -16,6 +16,7 @@ export interface DashboardSupervisorOptions {
   launch?: Launch
   probe?: Probe
   log?: (message: string) => void
+  managed?: boolean
 }
 
 function defaultRun(command: string): Run {
@@ -59,6 +60,9 @@ export class DashboardSupervisor {
   private readonly intervalMs: number
   private timer: NodeJS.Timeout | undefined
   private checking = false
+  private owned: ChildProcess | undefined
+  private stopped = false
+  private readonly managed: boolean
 
   constructor(options: DashboardSupervisorOptions) {
     this.command = options.command ?? 'hermes'
@@ -67,21 +71,31 @@ export class DashboardSupervisor {
     this.probe = options.probe ?? portIsListening
     this.log = options.log ?? console.info
     this.intervalMs = options.intervalMs ?? SUPERVISION_INTERVAL_MS
+    this.managed = options.managed === true
   }
 
   start(): void {
+    this.stopped = false
     void this.checkNow()
     this.timer = setInterval(() => { void this.checkNow() }, this.intervalMs)
     this.timer.unref()
   }
 
   stop(): void {
+    this.stopped = true
     if (this.timer) clearInterval(this.timer)
     this.timer = undefined
   }
 
   async restart(): Promise<void> {
     while (this.checking) await delay(25)
+    if (this.managed) {
+      if (!this.owned) throw new Error('此 Hermes 由外部服务管理,不能由桌面 App 停止。')
+      await this.stopOwned()
+      this.stopped = false
+      await this.checkNow()
+      return
+    }
     this.run(['dashboard', '--stop'])
     await this.checkNow()
     const deadline = Date.now() + RESTART_READY_TIMEOUT_MS
@@ -99,14 +113,35 @@ export class DashboardSupervisor {
       // Installation must not rewrite credentials or restart/rebind an existing
       // upstream. Only a missing managed service is started on loopback.
       const running = await this.probe('127.0.0.1', DASHBOARD_PORT)
-      if (running) return
+      if (running || this.stopped || this.owned) return
       this.log(`Hermes Dashboard is unavailable on 9119; starting it on ${this.dashboardHost}.`)
-      this.launch(['dashboard', '--host', this.dashboardHost])
+      if (this.managed) {
+        const env: NodeJS.ProcessEnv = { ...process.env, HERMES_PARENT_PID: String(process.pid) }
+        for (const key of Object.keys(env)) if (key.startsWith('HERMES_YAOYAO_') || key.startsWith('ELECTRON_')) delete env[key]
+        const child = spawn(this.command, ['dashboard', '--host', this.dashboardHost, '--no-open', '--skip-build'], {
+          env, detached: process.platform !== 'win32', stdio: 'ignore',
+        })
+        this.owned = child
+        child.once('error', error => { if (this.owned === child) this.owned = undefined; this.log(`Hermes 启动失败:${error.message}`) })
+        child.once('exit', () => { if (this.owned === child) this.owned = undefined })
+      } else this.launch(['dashboard', '--host', this.dashboardHost])
     } catch (error) {
       this.log(`Hermes Dashboard supervision failed: ${error instanceof Error ? error.message : String(error)}`)
     } finally {
       this.checking = false
     }
   }
+  async stopOwned(): Promise<void> {
+    const child = this.owned
+    if (!child || !child.pid || child.exitCode !== null || child.signalCode !== null) return
+    const exited = new Promise<void>(resolve => child.once('exit', () => resolve()))
+    child.kill('SIGTERM')
+    await Promise.race([exited, delay(3000)])
+    if (child.exitCode === null && child.signalCode === null) {
+      try { process.kill(process.platform === 'win32' ? child.pid : -child.pid, 'SIGKILL') }
+      catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
+      await exited
+    }
+  }
 
 }

+ 19 - 4
src/server/index.ts

@@ -6,21 +6,30 @@ import { createApplication, createNodeServer } from './app.js'
 import { loadServerConfig } from './config.js'
 import { DashboardSupervisor } from './dashboardSupervisor.js'
 import { isLocalAuthorizationTarget } from './loopbackAuthorization.js'
+import { acquireServiceInstance } from './serviceInstance.js'
+import { readReleaseManifest } from './releases.js'
+import { readBuildIdentity } from './buildIdentity.js'
 
 const config = loadServerConfig()
+const instance = acquireServiceInstance(config.home, readReleaseManifest(resolve(process.cwd(), 'release.json')).webVersion, process.env.HERMES_YAOYAO_DESKTOP === '1', readBuildIdentity(process.cwd()))
+process.once('exit', () => instance.release())
 const runtime = createApplication({
   config,
 })
 const dashboardSupervisor = config.superviseDashboard
   && isLocalAuthorizationTarget(config.upstream) && config.upstream.hostname === '127.0.0.1'
   && config.upstream.protocol === 'http:' && config.upstream.port === '9119'
-  ? new DashboardSupervisor({})
+  ? new DashboardSupervisor({ managed: process.env.HERMES_YAOYAO_DESKTOP === '1' })
   : undefined
 const nodeRuntime = createNodeServer(runtime)
 let closeFrontend = async (): Promise<void> => undefined
+// Native update admission runs before routes (including streamed requests).
+// The capability is loopback-only and never shared with the Web renderer.
+runtime.app.middleware.unshift(instance.middleware(shutdown, () => runtime.realtime.broker.idleForUpdate
+  && runtime.runners.idleForUpdate && runtime.workspaceRuntime.idleForUpdate))
 
 if (runtime.config.production) {
-  const dist = resolve(process.cwd(), 'dist')
+  const dist = resolve(process.env.HERMES_YAOYAO_STATIC_DIR || resolve(process.cwd(), 'dist'))
   runtime.app.use(serve(dist, { index: 'index.html' }))
   runtime.app.use(async (ctx) => {
     if (ctx.method !== 'GET' || !ctx.accepts('html') || !existsSync(resolve(dist, 'index.html'))) {
@@ -68,11 +77,12 @@ nodeRuntime.server.once('error', (error: NodeJS.ErrnoException) => {
   } else {
     console.error(`夭夭 Web could not start: ${error.message}`)
   }
-  process.exitCode = 1
+  void shutdown().finally(() => { process.exitCode = 1 })
 })
 nodeRuntime.server.listen(runtime.config.port, runtime.config.host, () => {
   const protocol = runtime.config.tlsCert ? 'https' : 'http'
   console.log(`夭夭 Web listening on ${protocol}://${runtime.config.host}:${runtime.config.port}`)
+  instance.publish(`${protocol}://${runtime.config.host}:${runtime.config.port}`)
   if (runtime.config.insecureLan) {
     console.warn('Warning: trusted-LAN HTTP mode is enabled; credentials are not encrypted in transit.')
   }
@@ -83,11 +93,16 @@ let closing = false
 async function shutdown(): Promise<void> {
   if (closing) return
   closing = true
+  instance.beginShutdown()
   try {
     dashboardSupervisor?.stop()
     await closeFrontend()
-    await nodeRuntime.close()
+    // Existing browser/SSE connections must not keep an explicitly stopped
+    // background process alive after its workers and VMs have been cleaned up.
+    await nodeRuntime.close(true)
+    await dashboardSupervisor?.stopOwned()
   } finally {
+    instance.release()
     process.exitCode = 0
   }
 }

+ 2 - 0
src/server/localAuth.ts

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

+ 171 - 0
src/server/localVm.ts

@@ -0,0 +1,171 @@
+import Router from '@koa/router'
+import { z } from 'zod'
+import { existsSync, mkdirSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { RunnerAgent } from '../runner/agent.js'
+import { UNCONFIGURED_COMPUTER_IMAGE, type RunnerConfiguration } from '../shared/runner.js'
+import type { LocalVmMode, LocalVmStatus } from '../shared/localVm.js'
+import type { WorkspaceAgent } from '../shared/workspace.js'
+import { parse, type WorkspaceStore } from './workspaceStore.js'
+import type { LocalAuthStore } from './localAuth.js'
+import type { WorkspaceNodes } from './workspaceGateway.js'
+import type { RunnerHub } from './runnerHub.js'
+import type { SharedComputers } from './sharedComputers.js'
+import type { ServerConfig } from './config.js'
+import { HttpError } from './errors.js'
+
+interface Grant {id:string;owner:string;runnerId:string;version:number;expiresAt:number}
+const exec=promisify(execFile)
+
+/** Settings owns preparation. Chats own each Agent's desktop and its lifecycle. */
+export class LocalVmService {
+  private url?:string
+  private managed?:{agent:RunnerAgent;abort:AbortController;done:Promise<void>}
+  private starting?:Promise<void>
+  private problem?:string
+  constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,
+    readonly hub:RunnerHub,readonly shared:SharedComputers,readonly config:ServerConfig) {
+    store.prepareAgent=(owner,agent)=>{const record=this.record();if(record&&this.mode(owner)==='shared')this.shared.attachLocalVm(owner,agent,record.id)}
+  }
+  allowed(id:string,runnerId:string){
+    const grant=this.store.get<Grant>('_system','local-vm-grant',id)
+    return !!grant&&grant.runnerId===runnerId&&grant.expiresAt>Date.now()&&this.auth.isAdminActive(grant.owner)&&this.auth.pushAuthorizationVersion(grant.owner)===grant.version
+  }
+  private mode(owner:string):LocalVmMode { return this.store.get<{mode:LocalVmMode}>(owner,'local-vm-preferences','local')?.mode??'per-bot' }
+  private record(){return this.hub.records().find(r=>r.enabled&&r.sourceNodeId==='local'&&r.sourceOwner==='_system')}
+  async start(url:string) {
+    this.url=url
+    const stored=this.store.get<{sealed:string}>('_system','local-vm-managed','local')
+    if(!stored||this.managed)return
+    const config=this.nodes.open<RunnerConfiguration>(stored.sealed)
+    if(!this.hub.records().some(r=>r.id===config.runnerId&&r.enabled))return
+    config.serverURL=url
+    const home=join(this.config.home,'runner-state','local-vm',config.runnerId)
+    mkdirSync(home,{recursive:true,mode:0o700})
+    const abort=new AbortController(),agent=new RunnerAgent(config,home)
+    const state={agent,abort,done:Promise.resolve()}
+    this.managed=state
+    state.done=agent.run(abort.signal).catch(error=>{this.problem=error instanceof Error?error.message:'本地虚拟机服务已断开'}).finally(()=>{if(this.managed===state)this.managed=undefined})
+  }
+  private async discover(){
+    for(const runtime of ['docker','podman'] as const) {
+      try {
+        await exec(runtime,['--version'],{timeout:5000})
+        try { await exec(runtime,['info'],{timeout:8000,maxBuffer:1024*1024});return {runtime,daemonUp:true} }
+        catch{return {runtime,daemonUp:false}}
+      }catch{}
+    }
+    return {runtime:undefined,daemonUp:false}
+  }
+  private installation(){
+    const hermesHome=join(homedir(),'.hermes'),hermesSource=join(hermesHome,'hermes-agent')
+    return {hermesHome,hermesSource,python:join(hermesSource,'venv','bin','python')}
+  }
+  private async ensure(owner:string) {
+    if(this.starting)await this.starting
+    if(this.record()){
+      if(this.url&&!this.managed)await this.start(this.url)
+      return this.record()!
+    }
+    this.starting=(async()=>{
+      if(!this.url)throw new HttpError(503,'本机 Web 服务尚未启动','local_vm_unavailable')
+      const detected=await this.discover()
+      if(!detected.runtime||!detected.daemonUp)throw new HttpError(409,'请先安装并启动 Docker 或 Podman','local_vm_runtime_required')
+      const {hermesHome,hermesSource,python}=this.installation()
+      if(!existsSync(python)||!existsSync(join(hermesSource,'run_agent.py')))throw new HttpError(409,'没有找到本机 Hermes 运行环境。请先安装 Hermes,或在高级连接设置中配置执行节点','local_vm_hermes_required')
+      const sources=await this.nodes.sources(owner)
+      const allowedProfiles=[...new Set(sources.sources.filter(s=>s.nodeId==='local').map(s=>s.profile))]
+      const registered=this.hub.enroll(owner,{name:'本机虚拟机',sourceNodeId:'local',allowedProfiles})
+      const config:RunnerConfiguration={protocol:1,serverURL:this.url,runnerId:registered.runner.id,token:registered.token,
+        hermesURL:this.config.upstream.origin,allowedProfiles,artifactRoots:[],computers:{runtime:detected.runtime,
+          imageId:UNCONFIGURED_COMPUTER_IMAGE,python,hermesSource,hermesHome,maxConcurrent:2,network:'public-proxy'}}
+      this.store.put('_system','local-vm-managed','local',{sealed:this.nodes.seal(config)})
+      await this.start(this.url)
+    })().finally(()=>{this.starting=undefined})
+    await this.starting
+    const record=this.record()!
+    const deadline=Date.now()+8000
+    while(!this.hub.summary(record).online&&Date.now()<deadline)await new Promise(r=>setTimeout(r,100))
+    return record
+  }
+  private grant(owner:string,id:string,runnerId:string){
+    const old=this.store.get<Grant>('_system','local-vm-grant',id)
+    if(old&&(old.owner!==owner||old.runnerId!==runnerId))throw new HttpError(409,'请求编号已用于其他操作','idempotency_conflict')
+    this.store.put('_system','local-vm-grant',id,{id,owner,runnerId,version:this.auth.pushAuthorizationVersion(owner)??0,expiresAt:Date.now()+3600000})
+  }
+  async status(owner:string):Promise<LocalVmStatus> {
+    const record=this.record()
+    if(record){
+      if(this.url&&!this.managed)await this.start(this.url)
+      if(!this.hub.summary(record).online){
+        const detected=await this.discover()
+        return {configured:true,...detected,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:this.problem??'本机执行环境正在连接,请稍后重新检查'}
+      }
+      const state=await this.hub.localVm(owner,record.id,{op:'status'}) as LocalVmStatus
+      const agents=this.store.list<WorkspaceAgent>(owner,'agent'),groups=this.store.list<{id:string;name:string}>(owner,'shared-computer')
+      return {...state,mode:this.mode(owner),instances:state.instances?.map(instance=>{
+        const agent=agents.find(a=>!a.archived&&(a.computerEnvironmentId??a.id)===instance.id&&a.execution==='computer')
+        return {...instance,name:agent?.computerEnvironmentName??agent?.name??groups.find(g=>g.id===instance.id)?.name??'保留的虚拟机',orphaned:!agent}
+      })}
+    }
+    const detected=await this.discover()
+    return {configured:false,...detected,image:false,mode:this.mode(owner),maxInstances:2,busy:false,problem:this.problem}
+  }
+  router(){
+    const router=new Router()
+    router.get('/api/app/admin/local-vm',async ctx=>{ctx.body=await this.status(this.auth.requireAdmin(ctx).id)})
+    router.post('/api/app/admin/local-vm/prepare',async ctx=>{
+      const actor=this.auth.requireAdmin(ctx),body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body)
+      const record=await this.ensure(actor.id);this.grant(actor.id,body.requestId,record.id)
+      ctx.body=await this.hub.localVm(actor.id,record.id,{op:'prepare',id:body.requestId})
+    })
+    router.put('/api/app/admin/local-vm/policy',async ctx=>{
+      const owner=this.auth.requireAdmin(ctx).id,body=parse(z.object({requestId:z.string().uuid(),mode:z.enum(['shared','per-bot']),maxInstances:z.number().int().min(1).max(4)}).strict(),(ctx.request as any).body)
+      const record=await this.ensure(owner);this.shared.assertLocalVmIdle(owner)
+      this.grant(owner,body.requestId,record.id)
+      await this.hub.localVm(owner,record.id,{op:'policy',id:body.requestId,mode:body.mode,maxInstances:body.maxInstances})
+      this.shared.setLocalVmMode(owner,body.mode,record.id)
+      this.store.put(owner,'local-vm-preferences','local',{mode:body.mode})
+      ctx.body=await this.status(owner)
+    })
+    router.post('/api/app/admin/local-vm/instances/:id/:action',async ctx=>{
+      const owner=this.auth.requireAdmin(ctx).id,id=parse(z.string().uuid(),ctx.params.id),action=parse(z.enum(['stop','remove']),ctx.params.action)
+      const body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body),record=this.record()
+      if(!record)throw new HttpError(409,'本地虚拟机尚未配置','local_vm_unavailable')
+      this.grant(owner,body.requestId,record.id)
+      ctx.body=await this.hub.localVm(owner,record.id,{op:'instance',id:body.requestId,environmentId:id,action})
+    })
+    router.get('/api/app/agents/:id/local-vm',async ctx=>{
+      const owner=this.auth.require(ctx).id,agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id)
+      this.nodes.requireSource(owner,agent)
+      if(agent.execution!=='computer'){ctx.body={enabled:false,agent};return}
+      try { ctx.body={enabled:true,agent,...await this.hub.computer(owner,agent,'detail',{},()=>this.nodes.requireSource(owner,agent)),mode:agent.computerEnvironmentId?'shared':'per-bot'} }
+      catch(error){ctx.body={enabled:true,agent,container:'missing',ready:false,image:false,problem:error instanceof Error?error.message:'本地虚拟机不可用'}}
+    })
+    router.put('/api/app/agents/:id/local-vm',ctx=>{
+      const owner=this.auth.require(ctx).id,body=parse(z.object({enabled:z.boolean()}).strict(),(ctx.request as any).body)
+      const agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);this.nodes.requireSource(owner,agent)
+      if(agent.remoteAgentId||agent.temporaryGoalId)throw new HttpError(409,'请在该 Agent 所属的电脑上设置虚拟机','local_vm_remote')
+      this.shared.assertLocalVmIdle(owner,[agent.id])
+      ctx.body=this.store.atomic(()=>{
+        if(!body.enabled)this.shared.detachLocalVm(owner,agent)
+        this.store.updateAgent(owner,agent.id,{execution:body.enabled?'computer':'profile'})
+        if(body.enabled&&this.mode(owner)==='shared'){const record=this.record();if(record)this.shared.setLocalVmMode(owner,'shared',record.id)}
+        return {agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
+      })
+    })
+    router.post('/api/app/agents/:id/local-vm/:action',async ctx=>{
+      const owner=this.auth.require(ctx).id,action=parse(z.enum(['create','start','stop','recreate','remove']),ctx.params.action)
+      const agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id)
+      if(agent.temporaryGoalId)throw new HttpError(409,'临时助手的虚拟机由当前任务管理','helper_task_bound')
+      if(agent.execution!=='computer'||agent.archived||agent.remoteAgentId)throw new HttpError(409,'此 Agent 未使用本地虚拟机','local_vm_disabled')
+      this.shared.assertLocalVmIdle(owner,agent.computerEnvironmentId?this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>a.computerEnvironmentId===agent.computerEnvironmentId).map(a=>a.id):[agent.id])
+      ctx.body=await this.hub.computer(owner,agent,'lifecycle',{action},()=>this.nodes.requireSource(owner,agent))
+    })
+    return router
+  }
+  async close(){this.managed?.abort.abort();await this.managed?.done}
+}

+ 3 - 0
src/server/realtimeBroker.ts

@@ -43,6 +43,9 @@ interface Upstream {
 
 /** Server-owned WS transports. Downstream detach never tears down an active upstream. */
 export class RealtimeBroker {
+  get idleForUpdate(): boolean {
+    return this.commands.size === 0 && [...this.upstreams.values()].every(upstream => upstream.pending.size === 0 && [...upstream.routes.values()].every(route => !route.active))
+  }
   protectedSession: (id: string) => boolean = () => false
   onNativeEvent: (owner: string, profile: string, storedId: string, frame: Frame) => void = () => {}
   onNativeGlobalEvent: (owner: string, type: string) => void = () => {}

Some files were not shown because too many files changed in this diff