Explorar el Código

新增 macOS App 并托管本地服务生命周期

LSamien hace 1 mes
padre
commit
1663f0f11c

+ 3 - 0
.gitignore

@@ -15,6 +15,9 @@ 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__/

+ 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>

+ 12 - 0
desktop/boot.js

@@ -0,0 +1,12 @@
+async function render() {
+  const state = await window.yaoyaoDesktop.status()
+  document.getElementById('status').textContent = state.message
+  document.getElementById('actions').hidden = state.phase !== 'error'
+  document.getElementById('status').setAttribute('role', state.phase === 'error' ? 'alert' : 'status')
+}
+document.getElementById('retry').onclick = async () => {
+  document.getElementById('retry').disabled = true
+  try { await window.yaoyaoDesktop.retry() } finally { document.getElementById('retry').disabled = false; await render() }
+}
+document.getElementById('logs').onclick = () => window.yaoyaoDesktop.logs()
+void render();setInterval(() => { void render() }, 500)

+ 76 - 0
desktop/lifecycle.test.mjs

@@ -0,0 +1,76 @@
+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(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
+  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')
+    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)
+    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)
+  } finally {
+    await desktop?.close().catch(() => {})
+    if (servicePID) { try { process.kill(servicePID, 'SIGTERM') } catch {} }
+    upstream.kill('SIGTERM')
+    await rm(home, { recursive: true, force: true })
+  }
+})

+ 132 - 0
desktop/main.mjs

@@ -0,0 +1,132 @@
+import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences } from 'electron'
+import { appendFileSync, mkdirSync, readFileSync } 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'
+
+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, 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 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 (!window || window.isDestroyed()) createWindow()
+    window.show(); window.focus()
+  }
+  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 }) => { safeExternal(url); 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', () => 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}`)
+    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', () => {})
+  app.on('before-quit', event => {
+    if (quitting) return
+    event.preventDefault()
+    if (closing) return
+    closing = true; clearInterval(timer)
+    void manager?.stop().catch(error => log(error.message)).finally(() => { quitting = true; app.quit() })
+  })
+  app.whenReady().then(async () => {
+    const packageVersion = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8')).webVersion
+    manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
+      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' })
+      },
+    })
+    Menu.setApplicationMenu(Menu.buildFromTemplate([
+      { label: '夭夭', submenu: [{ role: 'about', label: '关于夭夭' }, { type: 'separator' },
+        { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { if (manager.state.url) safeExternal(manager.state.url) } },
+        { label: '查看服务日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' },
+        { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' },
+        { type: 'separator' }, { role: 'quit', label: '退出夭夭' }] },
+      { 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: () => app.quit() }]))
+    tray.on('click', show)
+    createWindow()
+    powerMonitor.on('resume', () => { void manager.check() })
+    timer = setInterval(() => { void manager.check() }, 10_000); timer.unref()
+    await manager.start().catch(error => log(error.message))
+  }).catch(error => { log(error.stack || error.message); quitting = true; app.quit() })
+}

+ 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'),
+}))

+ 205 - 0
desktop/service-manager.mjs

@@ -0,0 +1,205 @@
+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) { this.state = value; this.options.onState?.(value) }
+  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))
+      throw new Error('服务身份与本地数据不匹配')
+    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') 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)
+      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, 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) {
+        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 >= 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(() => {})
+    }, 500 * this.restarts.length)
+  }
+  async check() {
+    if (this.state.phase !== 'ready') return
+    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('服务已停止')
+        await this.verify(record)
+      }
+    } catch {
+      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() {
+    this.stopping = true
+    clearTimeout(this.restartTimer)
+    ++this.generation
+    await this.starting?.catch(() => {})
+    await this.stopChild()
+    this.publish({ phase: 'stopped', message: '本地服务已停止' })
+  }
+}

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

@@ -0,0 +1,61 @@
+# 多 Agent、电脑环境与 macOS App 落地记录
+
+目标:完成本线程确认的整体方案。macOS App、Web、执行节点和电脑服务源码共同维护在 hermes-yaoyao;移动客户端继续使用各自仓库。当前工作分支为 `feat/bot-agent-teams`。
+
+本文用于保留完整范围和验收证据,不以某个局部功能完成代替整体完成。未经证据核对的项目保持待完成。
+
+## 完整交付范围
+
+### 桌面与服务
+
+- [x] macOS App 复用 Vue 界面,打包所需服务运行时,打开 App 自动启动或连接正确的服务。
+- [ ] 单实例、数据目录独占、身份/版本验证;兼容已有服务,不误停外部实例。
+- [ ] 关闭窗口保留任务、菜单栏恢复窗口、明确退出时安全停止自己管理的进程。
+- [ ] Hermes 检测与托管、服务启动进度、可恢复错误、日志、崩溃恢复与睡眠唤醒核对。
+- [ ] 可选后台常驻与开机启动;正常安装不强制开启。
+- [ ] 本机凭据保护、受限桌面通信、受信任页面与外部页面隔离。
+- [ ] arm64 App/安装包、签名完整性、更新与回退流程;公证和发布状态独立核实。
+
+### 团队执行
+
+- [x] 基础自主创建 Agent、团队、启动和查询任务,以及按轮次授权(`ac62d07`)。
+- [ ] 持久 Agent、临时助手、团队角色分离;记录创建来源与可恢复生命周期。
+- [ ] 子任务负责人、依赖、验收条件、产物归属、结构化分派和计划调整。
+- [ ] 成功/失败结果自动回到原会话,忙碌时排队;归档、停止、撤权后不复活旧任务。
+- [ ] 区分执行结束与目标完成;复核、返工、等待用户、受阻状态可追踪。
+- [ ] 持久投递与去重、重启修复、自动唤醒/递归/配额限制。
+- [ ] Agent、桌面和共享写入资源的并发限制;取消只影响所属任务。
+
+### 执行节点与电脑
+
+- [ ] Hermes 侧 Runner、版本/能力协商、受控工具传输,覆盖默认 Docker 部署与远程节点。
+- [ ] 权限取账号、Agent、任务、节点、电脑能力的交集;模型不能指定真实调用身份或凭据。
+- [ ] 电脑 Provider 统一接口与离线一致性测试;Docker、Podman和远程节点支持。
+- [ ] 独立电脑环境与受控 Hermes Worker;终端、文件、浏览器和子进程不会退回宿主执行。
+- [ ] 按需启动、资源上限、持久工作区、浏览器资料、产物导出和空闲清理。
+- [ ] 画面查看/控制分离、人工接管/交还、递增租约版本、旧请求拒绝。
+- [ ] 环境重建、数据恢复、更新维护、在途操作不确定性处理。
+- [ ] 可选可信团队共享环境、多桌面、独立浏览器资料及一致保存。
+- [ ] 扩展提供商适配边界,能力不足时明确降级,不伪装为同等隔离。
+
+### 跨端与验收
+
+- [ ] Web、macOS、iOS、Android 共享权限/状态/事件协议,按平台提供入口和安全降级。
+- [ ] 原始聊天展示回传结果、团队展示分派与产物、电脑面板支持查看与接管。
+- [ ] 隔离环境下完成启动、重启、撤权、重复请求、并发、停止竞态和数据恢复测试。
+- [ ] 真实打包 App 及真实电脑环境验收;脚本模型与真实模型证据分别记录。
+- [ ] 所有实现以中文 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 已完成。

+ 33 - 0
electron-builder.yml

@@ -0,0 +1,33 @@
+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
+      - release.json
+      - ui/**/*
+asar: true
+npmRebuild: false
+mac:
+  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

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 936 - 14
package-lock.json


+ 9 - 1
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"
   },
@@ -24,7 +25,11 @@
     "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:team-tools": "npm run build && playwright test -c playwright.team-tools.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"
   },
   "dependencies": {
     "@koa/bodyparser": "^5.0.0",
@@ -74,6 +79,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",

+ 26 - 0
scripts/build-desktop.mjs

@@ -0,0 +1,26 @@
+import { build } from 'esbuild'
+import { cp, mkdir, rm, readdir, readFile, writeFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
+
+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 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') || !/\.(mjs|cjs|html|css|png)$/.test(file)) continue
+  await cp(resolve(root, 'desktop', file), resolve(shell, file))
+}
+const manifest = JSON.parse(await readFile(resolve(root, 'release.json'), 'utf8'))
+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')

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

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

+ 11 - 3
src/server/index.ts

@@ -6,21 +6,26 @@ 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'
 
 const config = loadServerConfig()
+const instance = acquireServiceInstance(config.home, readReleaseManifest(resolve(process.cwd(), 'release.json')).webVersion, process.env.HERMES_YAOYAO_DESKTOP === '1')
+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
+runtime.app.use(instance.middleware(shutdown))
 
 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 +73,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.')
   }
@@ -87,7 +93,9 @@ async function shutdown(): Promise<void> {
     dashboardSupervisor?.stop()
     await closeFrontend()
     await nodeRuntime.close()
+    await dashboardSupervisor?.stopOwned()
   } finally {
+    instance.release()
     process.exitCode = 0
   }
 }

+ 72 - 0
src/server/serviceInstance.ts

@@ -0,0 +1,72 @@
+import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
+import { chmodSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { DatabaseSync } from 'node:sqlite'
+import type Koa from 'koa'
+
+export interface ServiceRecord {
+  protocol: 1
+  instanceId: string
+  pid: number
+  dataKey: string
+  version: string
+  url?: string
+  token: string
+  desktopOwned: boolean
+}
+
+/** Every executable entry takes this lock before opening any databases.
+ * Process death can be recovered; an unknown or live owner always fails closed. */
+export function acquireServiceInstance(home: string, version: string, desktopOwned = false) {
+  mkdirSync(home, { recursive: true, mode: 0o700 })
+  const root = realpathSync(home), file = join(root, 'service-instance.json')
+  // SQLite holds an OS-level lock, released by the kernel on process death.
+  // A separate database avoids blocking the application databases. No stale
+  // PID-file unlink race can evict a replacement process.
+  const lockPath = join(root, 'service-instance.sqlite3'), lock = new DatabaseSync(lockPath)
+  chmodSync(lockPath, 0o600)
+  try { lock.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE') }
+  catch { lock.close(); throw new Error('这份数据已由另一个夭夭服务使用,请连接已有服务。') }
+  const record: ServiceRecord = { protocol: 1, instanceId: randomUUID(), pid: process.pid,
+    dataKey: createHash('sha256').update(root).digest('hex'), version,
+    token: randomBytes(32).toString('base64url'), desktopOwned }
+  const write = () => {
+    const temporary = join(root, `service-instance-${record.instanceId}.tmp`)
+    writeFileSync(temporary, JSON.stringify(record), { mode: 0o600 })
+    renameSync(temporary, file)
+  }
+  try { write() } catch (error) { lock.close(); throw error }
+  let released = false
+  return {
+    record,
+    publish(url: string) { record.url = url; write() },
+    release() {
+      if (released) return
+      released = true
+      try {
+        const current = JSON.parse(readFileSync(file, 'utf8')) as ServiceRecord
+        if (current.instanceId === record.instanceId && current.pid === process.pid) {
+          rmSync(file)
+        }
+      } catch { /* Never remove a lock that can no longer be identified. */ }
+      finally { lock.close() }
+    },
+    middleware(shutdown: () => Promise<void>): Koa.Middleware {
+      return async (ctx, next) => {
+        if (ctx.path !== '/desktop/service') return next()
+        const received = createHash('sha256').update(ctx.get('x-yaoyao-desktop-token')).digest()
+        const expected = createHash('sha256').update(record.token).digest()
+        const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(ctx.req.socket.remoteAddress ?? '')
+        if (!local || ctx.get('origin') || !timingSafeEqual(received, expected)) { ctx.status = 403; ctx.body = { error: '桌面服务授权无效' }; return }
+        ctx.set('Cache-Control', 'no-store')
+        if (ctx.method === 'GET') {
+          const { token: _token, ...publicRecord } = record
+          ctx.body = publicRecord
+        } else if (ctx.method === 'DELETE' && desktopOwned) {
+          ctx.body = { stopping: true }
+          setImmediate(() => { void shutdown() })
+        } else { ctx.status = 405; ctx.body = { error: '此服务不接受桌面停止请求' } }
+      }
+    },
+  }
+}

+ 44 - 0
tests/server/serviceInstance.test.ts

@@ -0,0 +1,44 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import Koa from 'koa'
+import request from 'supertest'
+import { acquireServiceInstance } from '../../src/server/serviceInstance'
+
+let home: string
+let instances: ReturnType<typeof acquireServiceInstance>[]
+beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'yaoyao-service-lock-')); instances = [] })
+afterEach(() => { for (const instance of instances) instance.release(); rmSync(home, { recursive: true, force: true }) })
+const acquire = (desktop = true) => { const instance = acquireServiceInstance(home, '0.3.32', desktop); instances.push(instance); return instance }
+
+describe('service ownership', () => {
+  it('excludes a second process connection and releases ownership explicitly', () => {
+    const first = acquire()
+    expect(() => acquire()).toThrow('另一个')
+    first.release()
+    const next = acquire()
+    expect(next.record.instanceId).not.toBe(first.record.instanceId)
+    first.release()
+    expect(JSON.parse(readFileSync(join(home, 'service-instance.json'), 'utf8')).instanceId).toBe(next.record.instanceId)
+  })
+  it('uses a private capability and exact local service identity, not a public port probe', async () => {
+    const instance = acquire();instance.publish('http://127.0.0.1:12345')
+    let stops = 0
+    const app = new Koa().use(instance.middleware(async () => { stops++ }))
+    await request(app.callback()).get('/desktop/service').expect(403)
+    await request(app.callback()).get('/desktop/service').set('x-yaoyao-desktop-token', instance.record.token).set('Origin', 'http://evil.test').expect(403)
+    const result = await request(app.callback()).get('/desktop/service').set('x-yaoyao-desktop-token', instance.record.token).expect(200)
+    expect(result.body).toMatchObject({ protocol: 1, pid: process.pid, instanceId: instance.record.instanceId })
+    expect(result.body.token).toBeUndefined()
+    await request(app.callback()).delete('/desktop/service').set('x-yaoyao-desktop-token', instance.record.token).expect(200)
+    await new Promise(resolve => setImmediate(resolve))
+    expect(stops).toBe(1)
+  })
+  it('refuses stopping an externally managed CLI service', async () => {
+    const instance = acquire(false)
+    const app = new Koa().use(instance.middleware(async () => { throw new Error('must not stop') }))
+    await request(app.callback()).delete('/desktop/service').set('x-yaoyao-desktop-token', instance.record.token).expect(405)
+  })
+})

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio