Jelajahi Sumber

隔离桌面节点密钥并补齐默认容器交付验收

LSamien 1 bulan lalu
induk
melakukan
a5d4ad4cd5

+ 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("配置加解密失败,原配置保留") }

+ 6 - 2
desktop/lifecycle.test.mjs

@@ -1,10 +1,12 @@
 import { test } from 'node:test'
 import assert from 'node:assert/strict'
-import { mkdtemp, readFile, writeFile, stat, rm, mkdir } from 'node:fs/promises'
+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 } from 'node:child_process'
+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'
 
@@ -112,6 +114,8 @@ test('real desktop launches its bundled service, survives window close and recov
     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 })
   }
 })

+ 6 - 3
desktop/main.mjs

@@ -1,11 +1,12 @@
 import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences, dialog, safeStorage } from 'electron'
-import { appendFileSync, mkdirSync, readFileSync } from 'node:fs'
+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'
 
 const desktopRoot = dirname(fileURLToPath(import.meta.url))
 const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
@@ -132,9 +133,11 @@ else {
       },
     })
     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=>{if(!safeStorage.isEncryptionAvailable())throw new Error('系统钥匙串不可用,无法保存执行节点凭据');return safeStorage.encryptString(value)},
-      decrypt:bytes=>safeStorage.decryptString(bytes),
+      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'}}),
     })

+ 3 - 2
desktop/runner-manager.mjs

@@ -19,7 +19,7 @@ export class DesktopRunnerManager {
   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(this.options.decrypt(encrypted))) }
+    try { return this.options.validate(JSON.parse(await this.options.decrypt(encrypted))) }
     catch { throw new Error('无法解锁执行节点配置,请检查钥匙串或重新导入') }
   }
   importFile(path) {
@@ -30,7 +30,8 @@ export class DesktopRunnerManager {
       catch { throw new Error('执行节点配置无效,请从夭夭重新下载配置') }
       // Encrypt before stopping a working node: failed Keychain access preserves it.
       if(generation!==this.generation)return
-      const bytes = this.options.encrypt(JSON.stringify(config))
+      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 })

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

@@ -0,0 +1,88 @@
+# 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`。

+ 1 - 0
electron-builder.yml

@@ -23,6 +23,7 @@ extraResources:
       - guest_proxy.py
       - release.json
       - build-info.json
+      - keychain-helper
       - ui/**/*
 asar: true
 npmRebuild: false

+ 2 - 0
scripts/build-desktop.mjs

@@ -13,6 +13,8 @@ await build({ entryPoints: [resolve(root, 'src/server/index.ts')], outfile: reso
   define: { 'process.env.NODE_ENV': '"production"' }, sourcemap: true,
 })
 await buildRunner(out)
+execFileSync('xcrun',['swiftc','-O',resolve(root,'desktop/keychain-helper.swift'),'-o',resolve(out,'keychain-helper')],{stdio:'inherit'})
+execFileSync('xcrun',['swiftc','-O','-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'))

+ 64 - 0
tests/live/dockerPlatform.live.test.ts

@@ -0,0 +1,64 @@
+// @vitest-environment node
+import {it,expect} from 'vitest'
+import {mkdtemp,mkdir,writeFile,readFile,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {createServer} from 'node:http'
+import {execFile,spawn} from 'node:child_process'
+import {promisify} from 'node:util'
+import request from 'supertest'
+const exec=promisify(execFile)
+it.skipIf(!process.env.YAOYAO_PLATFORM_IMAGE||!process.env.YAOYAO_COMPUTER_IMAGE||!process.env.YAOYAO_HERMES_PYTHON)('executes an isolated task through the packaged Docker control plane and an external Runner',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-docker-platform-')),name=`yaoyao-platform-${randomUUID()}`,webHome=join(home,'web'),hermesHome=join(home,'hermes')
+  await mkdir(webHome);await mkdir(hermesHome)
+  const upstream=createServer(async(req,res)=>{
+    res.setHeader('Content-Type','application/json')
+    if(req.method==='GET'){res.end(JSON.stringify(req.url?.startsWith('/api/profiles')?{profiles:[{name:'default'}]}:req.url?.startsWith('/api/config')?{terminal:{cwd:'/workspace/docker-proof'}}:{ok:true}));return}
+    let body='';for await(const chunk of req)body+=chunk
+    const input=JSON.parse(body||'{}'),called=input.messages?.some((message:any)=>message.role==='tool'),exported=input.messages?.some((message:any)=>message.role==='tool'&&message.name==='computer_export')
+    const message=exported?{role:'assistant',content:'Docker 控制面任务完成'}:{role:'assistant',content:null,tool_calls:[{id:randomUUID(),type:'function',function:{name:called?'computer_export':'computer_shell',arguments:JSON.stringify(called?{path:'docker-proof.txt'}:{command:'printf docker-control-plane-proof > docker-proof.txt; id -u'})}}]}
+    if(input.stream){res.setHeader('Content-Type','text/event-stream');res.end('data: '+JSON.stringify({id:randomUUID(),object:'chat.completion.chunk',model:'fixture-model',choices:[{index:0,delta:{...message,...(message.tool_calls?{tool_calls:message.tool_calls.map((tool,index)=>({...tool,index}))}:{})},finish_reason:message.tool_calls?'tool_calls':'stop'}]})+'\n\ndata: [DONE]\n\n')}
+    else res.end(JSON.stringify({id:randomUUID(),object:'chat.completion',model:'fixture-model',choices:[{index:0,message,finish_reason:message.tool_calls?'tool_calls':'stop'}]}))
+  })
+  await new Promise<void>(done=>upstream.listen(0,'0.0.0.0',done));const upstreamPort=(upstream.address() as {port:number}).port
+  let child:ReturnType<typeof spawn>|undefined,exited:Promise<void>|undefined
+  try{
+    await exec('docker',['run','-d','--name',name,'--init','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true','--tmpfs','/tmp:mode=1777','--add-host','host.docker.internal:host-gateway','-p','127.0.0.1::15300','--mount',`type=volume,source=${name}-data,target=/var/lib/hermes-yaoyao`,'-e','HERMES_YAOYAO_ALLOW_INSECURE_LAN=1','-e','HERMES_YAOYAO_ALLOWED_HOSTS=localhost,127.0.0.1','-e',`HERMES_YAOYAO_UPSTREAM=http://host.docker.internal:${upstreamPort}`,process.env.YAOYAO_PLATFORM_IMAGE!])
+    const detail=JSON.parse((await exec('docker',['inspect',name])).stdout)[0],port=detail.NetworkSettings.Ports['15300/tcp'][0].HostPort,address=`http://127.0.0.1:${port}`,client=request.agent(address)
+    await expect.poll(async()=>{try{return (await client.get('/healthz')).status}catch{return 0}},{timeout:30000}).toBe(200)
+    expect(detail.Config.User).toBe('node');expect(detail.HostConfig.ReadonlyRootfs).toBe(true);expect(detail.HostConfig.CapDrop).toContain('ALL')
+    expect((await exec('docker',['exec',name,'id','-u'])).stdout.trim()).toBe('1000')
+    const bootstrap=await client.get('/api/app/bootstrap').expect(200)
+    const setup=await client.post('/api/app/setup').set('Origin',address).set('X-CSRF-Token',bootstrap.body.csrfToken).send({username:'docker-fixture',password:'docker-fixture-password'}).expect(200)
+    expect(setup.body.user.id).toBeTruthy()
+    const fresh=await client.get('/api/app/bootstrap').expect(200)
+    const post=(path:string,body:unknown)=>client.post(path).set('Origin',address).set('X-CSRF-Token',fresh.body.csrfToken).send(body)
+    const registration=await post('/api/app/admin/runners',{name:'容器外执行节点',allowedProfiles:['default']}).expect(201)
+    const runnerID=registration.body.runner.id
+    await writeFile(join(hermesHome,'config.yaml'),`model:\n  provider: custom\n  default: fixture-model\n  base_url: http://127.0.0.1:${upstreamPort}/v1\n  api_key: fixture-key\n  api_mode: chat_completions\nterminal:\n  cwd: /workspace/docker-proof\n`,{mode:0o600})
+    const config={protocol:1,serverURL:address,runnerId:runnerID,token:registration.body.token,hermesURL:`http://127.0.0.1:${upstreamPort}`,allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',network:'none',imageId:process.env.YAOYAO_COMPUTER_IMAGE,python:process.env.YAOYAO_HERMES_PYTHON,hermesSource:process.env.YAOYAO_HERMES_SOURCE,hermesHome}}
+    const path=join(home,'runner.json');await writeFile(path,JSON.stringify(config),{mode:0o600})
+    child=spawn(process.execPath,[resolve('.runner-build/runner.mjs'),'--config',path],{stdio:'ignore'})
+    exited=new Promise(done=>child!.once('exit',()=>done()))
+    await expect.poll(async()=>(await client.get('/api/app/admin/runners')).body.runners?.some((runner:any)=>runner.id===runnerID&&runner.online),{timeout:15000}).toBe(true)
+    const created=await post('/api/app/agents',{name:'Docker 隔离成员',profile:'default',execution:'computer'}).expect(201)
+    const agentID=created.body.agent.id,conversations=await client.get('/api/app/conversations').expect(200),conversation=conversations.body.conversations.find((item:any)=>item.memberIds.includes(agentID))
+    const sent=await post(`/api/app/conversations/${conversation.id}/messages`,{requestId:randomUUID(),content:'执行当前隔离工具并导出结果。'}).expect(202)
+    let result:any,message:any
+    await expect.poll(async()=>{result=(await client.get(`/api/app/conversations/${conversation.id}`)).body;message=result.messages?.find((item:any)=>item.role==='assistant'&&item.runId===sent.body.run.id);return message?.status??'pending'},{timeout:45000}).toMatch(/complete|failed|uncertain/)
+    expect(message.status,JSON.stringify(result)).toBe('complete')
+    expect(message.runId).toBe(sent.body.run.id)
+    const file=message.attachments?.[0]
+    expect(file?.id).toBeTruthy()
+    expect((await client.get(`/api/app/files/${file.id}/download`).expect(200)).text).toBe('docker-control-plane-proof')
+    expect(await readFile(join(home,'state',runnerID,'computer-workspaces',agentID,'docker-proof.txt'),'utf8')).toBe('docker-control-plane-proof')
+    const identity=await client.get('/api/status').expect(200);expect(identity.body.server_kind).toBe('yaoyao-web')
+  }catch(error){const logs=await exec('docker',['logs','--tail','35',name]).catch(()=>({stdout:'',stderr:''}));console.error(logs.stdout,logs.stderr);throw error}finally{
+    child?.kill('SIGTERM');await exited
+    await exec('docker',['rm','-f',name]).catch(()=>{})
+    await exec('docker',['volume','rm',name+'-data']).catch(()=>{})
+    await new Promise<void>(done=>{upstream.close(()=>done());upstream.closeAllConnections()})
+    await rm(home,{recursive:true,force:true})
+  }
+},100000)