ソースを参照

支持 Web 与插件配套安全升级

LSamien 2 ヶ月 前
親
コミット
71b4af8b39

+ 5 - 0
.env.example

@@ -10,3 +10,8 @@ HERMES_YAOYAO_SUPERVISE_DASHBOARD=0
 # HERMES_YAOYAO_TLS_CERT=/absolute/path/to/cert.pem
 # HERMES_YAOYAO_TLS_KEY=/absolute/path/to/key.pem
 # HERMES_YAOYAO_HOME=/Users/you/.hermes-yaoyao
+# 固定 Git 发布源。系统更新只接受这里发现的 vX.Y.Z 标签,不接受浏览器指定仓库。
+# HERMES_YAOYAO_RELEASE_SOURCE=https://git.samien.cn/samien/hermes-yaoyao.git
+# HERMES_YAOYAO_RELEASE_ROOT=/Users/you/.local/share/hermes-yaoyao
+# 系统更新默认仅允许从本机浏览器触发;可信网络确有需要时才显式开启。
+# HERMES_YAOYAO_ALLOW_REMOTE_UPDATE=0

+ 3 - 1
Dockerfile

@@ -5,6 +5,7 @@ FROM node:24-bookworm-slim AS builder
 WORKDIR /app
 
 COPY package.json package-lock.json ./
+COPY release.json ./
 RUN npm ci
 
 COPY tsconfig.json tsconfig.client.json tsconfig.server.json vite.config.ts ./
@@ -12,6 +13,7 @@ COPY index.html ./
 COPY public ./public
 COPY scripts ./scripts
 COPY src ./src
+COPY hermes-plugins/yaoyao ./hermes-plugins/yaoyao
 
 RUN npm run build \
   && npm prune --omit=dev
@@ -28,7 +30,7 @@ WORKDIR /app
 
 RUN install -d -o node -g node -m 0700 /var/lib/hermes-yaoyao
 
-COPY --chown=node:node package.json package-lock.json ./
+COPY --chown=node:node package.json package-lock.json release.json ./
 COPY --chown=node:node --from=builder /app/node_modules ./node_modules
 COPY --chown=node:node --from=builder /app/dist ./dist
 COPY --chown=node:node --from=builder /app/dist-server ./dist-server

+ 36 - 1
README.md

@@ -9,7 +9,7 @@
 插件安装接口安装。默认源为
 `https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao`。
 
-当前发布版本:**Git `v0.1.1` / 夭夭 Web `0.1.1` / Hermes Dashboard 插件 `1.7.1`**。
+当前发布版本:**Git `v0.2.0` / 夭夭 Web `0.2.0` / Hermes Dashboard 插件 `1.7.1`**。版本组合由仓库根目录的 `release.json` 唯一声明,并由 `npm run release:verify` 校验。
 
 需要由自动化 Agent 部署或升级时,请直接使用 [Agent 安装手册](docs/agent-install.md)。其中包含固定版本校验、备份、同步、单一 Dashboard 重载与验证步骤。
 
@@ -107,6 +107,41 @@ Content-Type: application/json
 9119 时,响应中的 `restartRequired` 为 `true`,需要由目标环境的服务管理器
 完成重启。
 
+### Web 与插件配套升级
+
+macOS 受管服务可在左下角 Agent 菜单中打开“系统更新”。它把一个 Git 发布标签
+中的 Web 与 Dashboard 插件视为同一发布单元,版本关系来自 `release.json`。
+
+升级接口固定读取 `HERMES_YAOYAO_RELEASE_SOURCE`,只接受发布源中最新的
+`vX.Y.Z` 标签,并在下载后核对标签解析出的 Git 提交和发布清单。浏览器不能传入
+仓库地址或任意提交。写接口仍受 Hermes 登录、Origin 和 CSRF 保护;默认还要求
+请求来自本机,可通过 `HERMES_YAOYAO_ALLOW_REMOTE_UPDATE=1` 明确允许远程管理。
+
+首次成功升级会把运行文件迁入:
+
+```text
+~/.local/share/hermes-yaoyao/
+├── releases/<版本>-<提交>/
+└── current -> releases/<版本>-<提交>
+```
+
+独立 updater 会先下载、构建和备份插件,再停止 8800 与 9119、原子切换插件和
+`current`、重装 LaunchAgent,最后验证 `/healthz`、`/readyz` 和实际插件版本。
+验证失败会自动恢复上一套 Web 与插件;用户数据始终留在
+`~/.hermes-yaoyao` 和 `~/.hermes/plugin-data/yaoyao`,不进入版本目录。
+
+相关接口:
+
+```text
+GET  /api/app/system/update/status
+POST /api/app/system/update/check
+POST /api/app/system/update/apply
+GET  /api/app/system/update/jobs/:jobID
+POST /api/app/system/update/rollback
+```
+
+容器和非 macOS 环境只显示版本状态,不执行服务内升级;应通过替换镜像更新。
+
 ## 开发
 
 ```bash

+ 398 - 0
bin/hermes-yaoyao-updater.mjs

@@ -0,0 +1,398 @@
+#!/usr/bin/env node
+
+import { execFileSync } from 'node:child_process'
+import {
+  copyFileSync,
+  cpSync,
+  existsSync,
+  lstatSync,
+  mkdirSync,
+  readFileSync,
+  readlinkSync,
+  renameSync,
+  rmSync,
+  symlinkSync,
+  writeFileSync,
+} from 'node:fs'
+import { dirname, join, relative, resolve, sep } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const label = 'com.samien.hermes-yaoyao'
+const uid = process.getuid?.() ?? 501
+const domain = `gui/${uid}`
+
+function fail(message) { throw new Error(message) }
+
+function readJSON(path) { return JSON.parse(readFileSync(path, 'utf8')) }
+
+function writeJSON(path, value) {
+  const temporary = `${path}.tmp-${process.pid}`
+  writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
+  renameSync(temporary, path)
+}
+
+export function validateManifest(value) {
+  if (!value || typeof value !== 'object' || value.schemaVersion !== 1) fail('release.json 格式无效')
+  for (const key of ['releaseVersion', 'webVersion', 'pluginVersion']) {
+    if (typeof value[key] !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value[key])) {
+      fail(`release.json ${key} 无效`)
+    }
+  }
+  if (value.releaseVersion !== value.webVersion || value.gitTag !== `v${value.releaseVersion}`) {
+    fail('release.json 版本组合不一致')
+  }
+  return value
+}
+
+function updateJob(jobPath, patch) {
+  const current = readJSON(jobPath)
+  writeJSON(jobPath, { ...current, ...patch, updatedAt: new Date().toISOString() })
+}
+
+function run(command, args, options = {}) {
+  return execFileSync(command, args, {
+    cwd: options.cwd,
+    env: options.env ?? process.env,
+    encoding: 'utf8',
+    stdio: options.inherit ? 'inherit' : 'pipe',
+    timeout: options.timeout ?? 120_000,
+    maxBuffer: 8 * 1_024 * 1_024,
+  })
+}
+
+function pathInside(path, parent) {
+  const target = resolve(path)
+  const root = resolve(parent)
+  return target.startsWith(`${root}${sep}`)
+}
+
+function removeInside(path, parent) {
+  if (!pathInside(path, parent)) fail(`拒绝清理非预期路径:${path}`)
+  rmSync(path, { recursive: true, force: true })
+}
+
+function loaded() {
+  try {
+    run('launchctl', ['print', `${domain}/${label}`])
+    return true
+  } catch {
+    return false
+  }
+}
+
+function stopService() {
+  if (loaded()) run('launchctl', ['bootout', `${domain}/${label}`])
+}
+
+function stopDashboard() {
+  try { run('hermes', ['dashboard', '--stop'], { timeout: 30_000 }) } catch { /* already stopped */ }
+}
+
+function enabledPlugins() {
+  const raw = run('hermes', ['config', 'get', 'plugins.enabled']).trim()
+  if (!raw) return []
+  const value = JSON.parse(raw)
+  if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
+    fail('Hermes plugins.enabled 不是字符串数组')
+  }
+  return value
+}
+
+function setEnabledPlugins(value) {
+  run('hermes', ['config', 'set', 'plugins.enabled', JSON.stringify(value)])
+}
+
+function ensureYaoyaoEnabled(previous) {
+  if (!previous.includes('yaoyao')) setEnabledPlugins([...previous, 'yaoyao'])
+}
+
+function atomicDirectory(source, target, scratchRoot, token) {
+  const parent = dirname(target)
+  mkdirSync(parent, { recursive: true })
+  const incoming = join(parent, `.${token}-incoming`)
+  const previous = join(parent, `.${token}-previous`)
+  removeInside(incoming, scratchRoot)
+  removeInside(previous, scratchRoot)
+  cpSync(source, incoming, { recursive: true, preserveTimestamps: true })
+  if (existsSync(target)) renameSync(target, previous)
+  renameSync(incoming, target)
+  return previous
+}
+
+function atomicFile(source, target, scratchRoot, token) {
+  const parent = dirname(target)
+  mkdirSync(parent, { recursive: true })
+  const incoming = join(parent, `.${token}-incoming.yaml`)
+  const previous = join(parent, `.${token}-previous.yaml`)
+  removeInside(incoming, scratchRoot)
+  removeInside(previous, scratchRoot)
+  copyFileSync(source, incoming)
+  if (existsSync(target)) renameSync(target, previous)
+  renameSync(incoming, target)
+  return previous
+}
+
+export function currentTarget(releaseRoot) {
+  const current = join(releaseRoot, 'current')
+  try {
+    const stat = lstatSync(current)
+    if (!stat.isSymbolicLink()) fail('发布目录 current 必须是符号链接')
+    return resolve(releaseRoot, readlinkSync(current))
+  } catch (error) {
+    if (error?.code === 'ENOENT') return undefined
+    throw error
+  }
+}
+
+export function switchCurrent(releaseRoot, target, token) {
+  mkdirSync(releaseRoot, { recursive: true, mode: 0o700 })
+  const current = join(releaseRoot, 'current')
+  const temporary = join(releaseRoot, `.current-${token}`)
+  removeInside(temporary, releaseRoot)
+  symlinkSync(relative(releaseRoot, target), temporary, 'dir')
+  renameSync(temporary, current)
+}
+
+function removeCurrent(releaseRoot) {
+  const current = join(releaseRoot, 'current')
+  try {
+    if (!lstatSync(current).isSymbolicLink()) fail('拒绝删除非符号链接 current')
+    rmSync(current)
+  } catch (error) {
+    if (error?.code !== 'ENOENT') throw error
+  }
+}
+
+function serviceEnvironment(serviceRoot, plan) {
+  return {
+    ...process.env,
+    HERMES_YAOYAO_SERVICE_ROOT: serviceRoot,
+    HERMES_YAOYAO_RELEASE_ROOT: plan.releaseRoot,
+    HERMES_YAOYAO_RELEASE_SOURCE: plan.source,
+  }
+}
+
+function startService(serviceRoot, plan, stableRoot = true) {
+  const cli = join(serviceRoot, 'bin', 'hermes-yaoyao.mjs')
+  if (!existsSync(cli)) fail(`服务入口不存在:${cli}`)
+  const env = serviceEnvironment(stableRoot ? join(plan.releaseRoot, 'current') : serviceRoot, plan)
+  if (!stableRoot) delete env.HERMES_YAOYAO_SERVICE_ROOT
+  run(process.execPath, [cli, 'service', 'install'], { env, timeout: 30_000 })
+}
+
+async function responseJSON(url) {
+  const response = await fetch(url, { signal: AbortSignal.timeout(3_000), cache: 'no-store' })
+  if (!response.ok) fail(`${url} 返回 HTTP ${response.status}`)
+  return response.json()
+}
+
+async function verifyRuntime(pluginVersion, timeoutMs = 45_000) {
+  const deadline = Date.now() + timeoutMs
+  let lastError = ''
+  while (Date.now() < deadline) {
+    try {
+      const health = await responseJSON('http://127.0.0.1:8800/healthz')
+      const ready = await responseJSON('http://127.0.0.1:8800/readyz')
+      const plugins = await responseJSON('http://127.0.0.1:9119/api/dashboard/plugins')
+      const plugin = Array.isArray(plugins) ? plugins.find(item => item?.name === 'yaoyao') : undefined
+      const pluginMatches = pluginVersion ? plugin?.version === pluginVersion : plugin === undefined
+      if (health.ok === true && ready.ok === true && pluginMatches) return
+      lastError = pluginVersion
+        ? `运行版本不匹配:期望插件 ${pluginVersion}`
+        : '上一版本未安装夭夭插件,但回滚后仍检测到插件'
+    } catch (error) {
+      lastError = error instanceof Error ? error.message : String(error)
+    }
+    await new Promise(resolvePromise => setTimeout(resolvePromise, 500))
+  }
+  fail(`升级后健康检查失败:${lastError}`)
+}
+
+export function backupPlugin(plan, jobID) {
+  const pluginRoot = join(plan.hermesHome, 'plugins', 'yaoyao')
+  const backupRoot = join(plan.dataHome, 'backups', 'system-update', jobID)
+  const pluginPreviouslyInstalled = existsSync(join(pluginRoot, 'dashboard', 'manifest.json'))
+  mkdirSync(backupRoot, { recursive: true, mode: 0o700 })
+  if (existsSync(join(pluginRoot, 'dashboard'))) {
+    cpSync(join(pluginRoot, 'dashboard'), join(backupRoot, 'dashboard'), { recursive: true, preserveTimestamps: true })
+  }
+  if (existsSync(join(pluginRoot, 'plugin.yaml'))) copyFileSync(join(pluginRoot, 'plugin.yaml'), join(backupRoot, 'plugin.yaml'))
+  return { pluginRoot, backupRoot, pluginPreviouslyInstalled }
+}
+
+export function restorePlugin(pluginRoot, backupRoot, token, pluginPreviouslyInstalled = true) {
+  const dashboardBackup = join(backupRoot, 'dashboard')
+  const manifestBackup = join(backupRoot, 'plugin.yaml')
+  if (!pluginPreviouslyInstalled) {
+    removeInside(join(pluginRoot, 'dashboard'), pluginRoot)
+    removeInside(join(pluginRoot, 'plugin.yaml'), pluginRoot)
+    return
+  }
+  if (!existsSync(dashboardBackup) || !existsSync(manifestBackup)) fail('插件回滚备份不完整')
+  const oldDashboard = atomicDirectory(dashboardBackup, join(pluginRoot, 'dashboard'), pluginRoot, `${token}-restore-dashboard`)
+  const oldManifest = atomicFile(manifestBackup, join(pluginRoot, 'plugin.yaml'), pluginRoot, `${token}-restore-manifest`)
+  removeInside(oldDashboard, pluginRoot)
+  removeInside(oldManifest, pluginRoot)
+}
+
+function backupPluginVersion(backupRoot) {
+  return String(readJSON(join(backupRoot, 'dashboard', 'manifest.json')).version ?? '')
+}
+
+function stageRelease(job, jobPath) {
+  const { plan, target, id } = job
+  if (!target) fail('升级任务缺少目标版本')
+  const releasesRoot = join(plan.releaseRoot, 'releases')
+  mkdirSync(releasesRoot, { recursive: true, mode: 0o700 })
+  const staging = join(releasesRoot, `.staging-${id}`)
+  removeInside(staging, releasesRoot)
+  updateJob(jobPath, { state: 'downloading', message: `正在下载 ${target.gitTag}` })
+  run('git', ['clone', '--quiet', '--depth', '1', '--single-branch', '--branch', target.gitTag, plan.source, staging], { timeout: 180_000 })
+  const manifest = validateManifest(readJSON(join(staging, 'release.json')))
+  for (const field of ['schemaVersion', 'releaseVersion', 'webVersion', 'pluginVersion', 'gitTag']) {
+    if (manifest[field] !== target[field]) fail('下载的发布清单与检查结果不一致')
+  }
+  const commit = run('git', ['rev-parse', 'HEAD'], { cwd: staging }).trim()
+  if (!/^[0-9a-f]{40,64}$/.test(plan.targetCommit || '') || commit !== plan.targetCommit) {
+    fail('下载提交与检查时锁定的发布提交不一致')
+  }
+
+  updateJob(jobPath, { state: 'building', message: `正在构建 Web ${target.webVersion}` })
+  run('npm', ['ci'], { cwd: staging, inherit: true, timeout: 600_000 })
+  run('npm', ['run', 'build'], { cwd: staging, inherit: true, timeout: 600_000 })
+  const finalRoot = join(releasesRoot, `${target.releaseVersion}-${commit.slice(0, 12)}`)
+  if (existsSync(finalRoot)) removeInside(staging, releasesRoot)
+  else renameSync(staging, finalRoot)
+  return { finalRoot, commit }
+}
+
+async function runUpdate(jobPath, job) {
+  const { plan, target, id } = job
+  if (!target) fail('升级任务缺少目标发布清单')
+  const { finalRoot, commit } = stageRelease(job, jobPath)
+  const pluginSource = join(finalRoot, 'hermes-plugins', 'yaoyao')
+  const pluginManifest = readJSON(join(pluginSource, 'dashboard', 'manifest.json'))
+  if (pluginManifest.version !== target.pluginVersion) fail('构建产物中的插件版本与发布清单不一致')
+
+  const previousCurrentTarget = currentTarget(plan.releaseRoot)
+  const { pluginRoot, backupRoot, pluginPreviouslyInstalled } = backupPlugin(plan, id)
+  const previousPluginVersion = pluginPreviouslyInstalled ? backupPluginVersion(backupRoot) : ''
+  const previousEnabledPlugins = enabledPlugins()
+  let transitioned = false
+  let previousDashboard = ''
+  let previousPluginYaml = ''
+  try {
+    updateJob(jobPath, { state: 'installing', message: `正在安装插件 ${target.pluginVersion}` })
+    stopService()
+    stopDashboard()
+    transitioned = true
+    previousDashboard = atomicDirectory(
+      join(pluginSource, 'dashboard'), join(pluginRoot, 'dashboard'), pluginRoot, `${id}-dashboard`,
+    )
+    previousPluginYaml = atomicFile(
+      join(pluginSource, 'plugin.yaml'), join(pluginRoot, 'plugin.yaml'), pluginRoot, `${id}-manifest`,
+    )
+    ensureYaoyaoEnabled(previousEnabledPlugins)
+    switchCurrent(plan.releaseRoot, finalRoot, id)
+
+    updateJob(jobPath, { state: 'restarting', message: '正在启动新版本服务' })
+    startService(join(plan.releaseRoot, 'current'), plan, true)
+    updateJob(jobPath, { state: 'verifying', message: '正在验证 8800、9119 和插件版本' })
+    await verifyRuntime(target.pluginVersion)
+    removeInside(previousDashboard, pluginRoot)
+    removeInside(previousPluginYaml, pluginRoot)
+    writeJSON(join(dirname(jobPath), 'last-success.json'), {
+      jobId: id,
+      previousCurrentTarget,
+      previousServiceRoot: plan.previousServiceRoot,
+      previousPluginVersion,
+      pluginPreviouslyInstalled,
+      previousEnabledPlugins,
+      backupRoot,
+      target,
+      finalRoot,
+      commit,
+    })
+    updateJob(jobPath, { state: 'succeeded', message: `已升级到 Web ${target.webVersion} + 插件 ${target.pluginVersion}` })
+  } catch (error) {
+    const message = error instanceof Error ? error.message : String(error)
+    if (transitioned) {
+      updateJob(jobPath, { state: 'rolling_back', message: '升级失败,正在自动恢复上一版本', error: message })
+      try {
+        stopService()
+        stopDashboard()
+        restorePlugin(pluginRoot, backupRoot, id, pluginPreviouslyInstalled)
+        setEnabledPlugins(previousEnabledPlugins)
+        if (previousCurrentTarget) {
+          switchCurrent(plan.releaseRoot, previousCurrentTarget, `${id}-rollback`)
+          startService(join(plan.releaseRoot, 'current'), plan, true)
+        } else {
+          removeCurrent(plan.releaseRoot)
+          startService(plan.previousServiceRoot, plan, false)
+        }
+        await verifyRuntime(previousPluginVersion)
+        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 runRollback(jobPath, job) {
+  const { plan, id } = job
+  const recordPath = join(dirname(jobPath), 'last-success.json')
+  if (!existsSync(recordPath)) fail('没有可回滚的上一版本')
+  const record = readJSON(recordPath)
+  updateJob(jobPath, { state: 'rolling_back', message: '正在恢复上一版本' })
+  stopService()
+  stopDashboard()
+  restorePlugin(
+    join(plan.hermesHome, 'plugins', 'yaoyao'),
+    record.backupRoot,
+    id,
+    record.pluginPreviouslyInstalled !== false,
+  )
+  if (Array.isArray(record.previousEnabledPlugins)) setEnabledPlugins(record.previousEnabledPlugins)
+  if (record.previousCurrentTarget) {
+    switchCurrent(plan.releaseRoot, record.previousCurrentTarget, id)
+    startService(join(plan.releaseRoot, 'current'), plan, true)
+  } else {
+    removeCurrent(plan.releaseRoot)
+    startService(record.previousServiceRoot, plan, false)
+  }
+  await verifyRuntime(record.previousPluginVersion)
+  rmSync(recordPath, { force: true })
+  updateJob(jobPath, { state: 'rolled_back', message: `已回滚到插件 ${record.previousPluginVersion}` })
+}
+
+async function main() {
+  const [, , command, flag, jobPathValue] = process.argv
+  if (command !== 'run' || flag !== '--job' || !jobPathValue) fail('用法:hermes-yaoyao-updater run --job <path>')
+  const jobPath = resolve(jobPathValue)
+  const job = readJSON(jobPath)
+  try {
+    if (job.operation === 'update') await runUpdate(jobPath, job)
+    else if (job.operation === 'rollback') await runRollback(jobPath, job)
+    else fail('未知升级任务类型')
+  } catch (error) {
+    updateJob(jobPath, {
+      state: 'failed',
+      message: job.operation === 'rollback' ? '回滚失败,需要人工处理' : '升级失败',
+      error: error instanceof Error ? error.message : String(error),
+    })
+  } finally {
+    const lockPath = join(dirname(jobPath), 'active.lock')
+    try {
+      const raw = readFileSync(lockPath, 'utf8').trim()
+      let lockJobID = raw
+      try { lockJobID = JSON.parse(raw).jobID } catch { /* legacy lock */ }
+      if (lockJobID === job.id) rmSync(lockPath)
+    } catch { /* already released */ }
+  }
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main()

+ 12 - 5
bin/hermes-yaoyao.mjs

@@ -8,14 +8,15 @@ import { dirname, join, resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
 
 const label = 'com.samien.hermes-yaoyao'
-const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+const sourceProjectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+const serviceRoot = resolve(process.env.HERMES_YAOYAO_SERVICE_ROOT || sourceProjectRoot)
 const uid = process.getuid?.() ?? 501
 const domain = `gui/${uid}`
 const launchAgents = join(homedir(), 'Library', 'LaunchAgents')
 const plistPath = join(launchAgents, `${label}.plist`)
 const dataHome = process.env.HERMES_YAOYAO_HOME || join(homedir(), '.hermes-yaoyao')
 const logDir = join(homedir(), 'Library', 'Logs')
-const serverEntry = join(projectRoot, 'dist-server', 'server', 'index.js')
+const serverEntry = join(serviceRoot, 'dist-server', 'server', 'index.js')
 
 function run(command, args, options = {}) {
   return execFileSync(command, args, { encoding: 'utf8', stdio: options.inherit ? 'inherit' : 'pipe' })
@@ -41,11 +42,17 @@ function environment() {
     'HERMES_YAOYAO_TLS_KEY',
     'HERMES_YAOYAO_ALLOW_INSECURE_LAN',
     'HERMES_YAOYAO_SUPERVISE_DASHBOARD',
+    'HERMES_YAOYAO_RELEASE_SOURCE',
+    'HERMES_YAOYAO_RELEASE_ROOT',
+    'HERMES_YAOYAO_ALLOW_REMOTE_UPDATE',
+    'HERMES_YAOYAO_SERVICE_ROOT',
   ]
   return Object.fromEntries(allowed.flatMap(key => process.env[key] ? [[key, process.env[key]]] : []))
 }
 
-export function launchAgentPlist() {
+export function launchAgentPlist(options = {}) {
+  const plistServiceRoot = resolve(options.serviceRoot || serviceRoot)
+  const plistServerEntry = join(plistServiceRoot, 'dist-server', 'server', 'index.js')
   const node = process.execPath
   const envEntries = {
     PATH: `${dirname(node)}:${join(homedir(), '.local', 'bin')}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`,
@@ -67,8 +74,8 @@ export function launchAgentPlist() {
   <dict>
     <key>Label</key><string>${label}</string>
     <key>ProgramArguments</key>
-    <array><string>${escapeXml(node)}</string><string>${escapeXml(serverEntry)}</string></array>
-    <key>WorkingDirectory</key><string>${escapeXml(projectRoot)}</string>
+    <array><string>${escapeXml(node)}</string><string>${escapeXml(plistServerEntry)}</string></array>
+    <key>WorkingDirectory</key><string>${escapeXml(plistServiceRoot)}</string>
     <key>EnvironmentVariables</key>
     <dict>
 ${envXml}

+ 30 - 5
docs/agent-install.md

@@ -6,8 +6,8 @@
 
 | 项目 | 版本 | 校验位置 |
 | --- | --- | --- |
-| Git 发布标签 | `v0.1.1` | `git describe --tags --exact-match HEAD` |
-| 夭夭 Web | `0.1.1` | `package.json` 的 `version` |
+| Git 发布标签 | `v0.2.0` | `release.json` 的 `gitTag` 与 `git describe --tags --exact-match HEAD` |
+| 夭夭 Web | `0.2.0` | `release.json` 与 `package.json` 的 `version` |
 | Hermes Dashboard 插件 | `1.7.1` | `hermes-plugins/yaoyao/dashboard/manifest.json` 的 `version` |
 
 以下步骤以仓库根目录为工作目录。不要使用浮动分支替代发布标签。
@@ -81,13 +81,14 @@ curl --fail --silent --show-error http://127.0.0.1:9119/api/auth/providers
 ## 1. 获取并校验指定发布版本
 
 ```bash
-RELEASE_VERSION=v0.1.1
+RELEASE_VERSION=v0.2.0
 git fetch --tags
 git checkout "$RELEASE_VERSION"
 
 test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_VERSION"
-test "$(node -p \"require('./package.json').version\")" = "0.1.1"
+test "$(node -p \"require('./package.json').version\")" = "0.2.0"
 test "$(node -e \"console.log(require('./hermes-plugins/yaoyao/dashboard/manifest.json').version)\")" = "1.7.1"
+npm run release:verify
 ```
 
 这会进入 detached HEAD 状态,属于部署发布版本的预期行为。若 Agent 负责修改代码,应另行创建工作分支,不要在此发布检出上修改。
@@ -223,7 +224,31 @@ plutil -lint ~/Library/LaunchAgents/com.samien.hermes-yaoyao.plist
 launchctl load -w ~/Library/LaunchAgents/com.samien.hermes-yaoyao.plist
 ```
 
-## 7. 失败处理
+## 7. 通过受管服务进行配套升级
+
+首次按第 6 步安装 Web `0.2.0` 后,可在左下角 Agent 菜单打开“系统更新”。
+检查更新只读取固定的 `HERMES_YAOYAO_RELEASE_SOURCE`,并锁定最新 `vX.Y.Z`
+标签解析到的 Git 提交;执行升级前仍会检查插件持久目录是否安全。
+
+升级任务由 8800 外部的独立 updater 执行。运行文件会安装到
+`~/.local/share/hermes-yaoyao/releases/`,LaunchAgent 改为指向稳定的
+`current` 符号链接。切换期间 8800 和 9119 会短暂不可用,任务状态保存在
+`~/.hermes-yaoyao/updates/`,新服务启动后可继续读取。
+
+默认只能从目标 Mac 本机触发“升级”和“回滚”;仅当用户明确允许远程系统管理
+时,才在重新安装 LaunchAgent 前设置 `HERMES_YAOYAO_ALLOW_REMOTE_UPDATE=1`。
+Docker 和其他非 macOS 部署必须替换镜像,不能调用服务内升级接口。
+
+升级成功后至少验证:
+
+```bash
+curl --noproxy '*' --fail --silent http://127.0.0.1:8800/healthz
+curl --noproxy '*' --fail --silent http://127.0.0.1:8800/readyz
+curl --noproxy '*' --fail --silent http://127.0.0.1:9119/api/dashboard/plugins
+readlink ~/.local/share/hermes-yaoyao/current
+```
+
+## 8. 失败处理
 
 1. 记录失败的命令、退出码、Dashboard 状态和 `9119` 监听信息。
 2. 若插件重载失败,使用第 2 步生成的 `$BACKUP_DIR` 恢复到 `$PLUGIN_DIR`,执行 `hermes dashboard --stop`,再由 8800 监督器重启 Dashboard。

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.1.1",
+  "version": "0.2.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.1.1",
+      "version": "0.2.0",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 3 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.1.1",
+  "version": "0.2.0",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",
@@ -13,7 +13,7 @@
   },
   "scripts": {
     "dev": "cross-env NODE_ENV=development tsx watch src/server/index.ts",
-    "build": "npm run brand:verify && vue-tsc -b && vite build && tsc -p tsconfig.server.json",
+    "build": "npm run brand:verify && npm run release:verify && vue-tsc -b && vite build && tsc -p tsconfig.server.json",
     "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",
@@ -21,6 +21,7 @@
     "test:e2e": "npm run build && playwright test",
     "test:live": "cross-env HERMES_YAOYAO_LIVE_TEST=1 vitest run tests/live",
     "brand:verify": "node scripts/verify-brand.mjs",
+    "release:verify": "node scripts/verify-release.mjs",
     "verify:lifecycle": "node scripts/verify-lifecycle.mjs"
   },
   "dependencies": {

+ 7 - 0
release.json

@@ -0,0 +1,7 @@
+{
+  "schemaVersion": 1,
+  "releaseVersion": "0.2.0",
+  "webVersion": "0.2.0",
+  "pluginVersion": "1.7.1",
+  "gitTag": "v0.2.0"
+}

+ 31 - 0
scripts/verify-release.mjs

@@ -0,0 +1,31 @@
+#!/usr/bin/env node
+
+import { readFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
+
+const root = resolve(import.meta.dirname, '..')
+const json = async path => JSON.parse(await readFile(resolve(root, path), 'utf8'))
+const release = await json('release.json')
+const pkg = await json('package.json')
+const lock = await json('package-lock.json')
+const pluginManifest = await json('hermes-plugins/yaoyao/dashboard/manifest.json')
+const pluginYaml = await readFile(resolve(root, 'hermes-plugins/yaoyao/plugin.yaml'), 'utf8')
+const pluginYamlVersion = pluginYaml.match(/^version:\s*["']?([^"'\s]+)["']?\s*$/m)?.[1]
+
+const failures = []
+if (release.schemaVersion !== 1) failures.push('release.json schemaVersion 必须为 1')
+if (release.releaseVersion !== release.webVersion) failures.push('releaseVersion 与 webVersion 必须一致')
+if (release.gitTag !== `v${release.releaseVersion}`) failures.push('gitTag 必须为 v<releaseVersion>')
+if (pkg.version !== release.webVersion) failures.push('package.json 版本与 release.json 不一致')
+if (lock.version !== release.webVersion || lock.packages?.['']?.version !== release.webVersion) {
+  failures.push('package-lock.json 版本与 release.json 不一致')
+}
+if (pluginManifest.version !== release.pluginVersion) failures.push('插件 manifest.json 版本与 release.json 不一致')
+if (pluginYamlVersion !== release.pluginVersion) failures.push('plugin.yaml 版本与 release.json 不一致')
+
+if (failures.length) {
+  for (const failure of failures) process.stderr.write(`- ${failure}\n`)
+  process.exitCode = 1
+} else {
+  process.stdout.write(`发布版本校验通过:Web ${release.webVersion} + 插件 ${release.pluginVersion}\n`)
+}

+ 73 - 0
src/client/api/systemUpdate.ts

@@ -0,0 +1,73 @@
+import type { JsonValue } from '@shared/types'
+import { apiRequest } from './client'
+
+export interface ReleaseManifest {
+  schemaVersion: 1
+  releaseVersion: string
+  webVersion: string
+  pluginVersion: string
+  gitTag: string
+}
+
+export type UpdateJobState =
+  | 'queued'
+  | 'downloading'
+  | 'building'
+  | 'installing'
+  | 'restarting'
+  | 'verifying'
+  | 'rolling_back'
+  | 'succeeded'
+  | 'failed'
+  | 'rolled_back'
+
+export interface UpdateJob {
+  id: string
+  operation: 'update' | 'rollback'
+  state: UpdateJobState
+  message: string
+  createdAt: string
+  updatedAt: string
+  target?: ReleaseManifest
+  error?: string
+}
+
+export interface SystemUpdateStatus {
+  current: ReleaseManifest
+  installedPluginVersion?: string
+  versionsMatch: boolean
+  installationMode: 'source' | 'release'
+  latest?: ReleaseManifest
+  updateAvailable: boolean
+  supported: boolean
+  unsupportedReason?: string
+  canRollback: boolean
+  job?: UpdateJob
+}
+
+export function systemUpdateStatus(): Promise<SystemUpdateStatus> {
+  return apiRequest('/api/app/system/update/status')
+}
+
+export function checkSystemUpdate(): Promise<SystemUpdateStatus> {
+  return apiRequest('/api/app/system/update/check', { method: 'POST', body: {}, timeoutMs: 90_000 })
+}
+
+export function applySystemUpdate(targetVersion: string): Promise<UpdateJob> {
+  return apiRequest('/api/app/system/update/apply', {
+    method: 'POST',
+    body: { targetVersion } as JsonValue,
+    timeoutMs: 90_000,
+  })
+}
+
+export function systemUpdateJob(jobID: string): Promise<UpdateJob> {
+  return apiRequest(`/api/app/system/update/jobs/${encodeURIComponent(jobID)}`, {
+    notifyUnauthorized: false,
+    timeoutMs: 5_000,
+  })
+}
+
+export function rollbackSystemUpdate(): Promise<UpdateJob> {
+  return apiRequest('/api/app/system/update/rollback', { method: 'POST', body: {} })
+}

+ 158 - 0
src/client/components/app/SystemUpdateDialog.vue

@@ -0,0 +1,158 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import {
+  applySystemUpdate,
+  checkSystemUpdate,
+  rollbackSystemUpdate,
+  systemUpdateJob,
+  systemUpdateStatus,
+  type SystemUpdateStatus,
+  type UpdateJob,
+} from '@/api/systemUpdate'
+
+const props = defineProps<{ open: boolean }>()
+const emit = defineEmits<{ close: [] }>()
+const status = ref<SystemUpdateStatus>()
+const job = ref<UpdateJob>()
+const busy = ref(false)
+const checking = ref(false)
+const error = ref('')
+let pollToken = 0
+
+const terminal = computed(() => job.value && ['succeeded', 'failed', 'rolled_back'].includes(job.value.state))
+const active = computed(() => job.value && !terminal.value)
+const currentPlugin = computed(() => status.value?.installedPluginVersion || '未安装')
+const canApply = computed(() => Boolean(status.value?.supported && status.value.updateAvailable && status.value.latest && !active.value && !busy.value))
+
+function stopPolling() { pollToken += 1 }
+function reloadPage() { window.location.reload() }
+
+async function refresh(checkRemote = false) {
+  error.value = ''
+  checking.value = checkRemote
+  try {
+    status.value = checkRemote ? await checkSystemUpdate() : await systemUpdateStatus()
+    if (status.value.job) job.value = status.value.job
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : '无法读取系统版本'
+  } finally {
+    checking.value = false
+  }
+}
+
+async function poll(next: UpdateJob) {
+  job.value = next
+  const token = ++pollToken
+  for (let attempt = 0; attempt < 300 && token === pollToken; attempt += 1) {
+    if (['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) break
+    await new Promise(resolvePromise => window.setTimeout(resolvePromise, 1_000))
+    try {
+      job.value = await systemUpdateJob(next.id)
+    } catch {
+      // 8800 会在原子切换期间短暂不可用;继续等待新服务读取同一任务文件。
+    }
+  }
+  if (token === pollToken && job.value && !['succeeded', 'failed', 'rolled_back'].includes(job.value.state)) {
+    error.value = '升级仍在后台执行,请稍后重新打开此页面查看'
+  }
+  if (token === pollToken) await refresh(false)
+}
+
+async function applyUpdate() {
+  const target = status.value?.latest
+  if (!target || !window.confirm(`升级到 Web ${target.webVersion} + 插件 ${target.pluginVersion}?运行服务会短暂重启。`)) return
+  busy.value = true
+  error.value = ''
+  try {
+    await poll(await applySystemUpdate(target.releaseVersion))
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : '无法启动系统升级'
+  } finally {
+    busy.value = false
+  }
+}
+
+async function rollback() {
+  if (!window.confirm('回滚到上一次升级前的 Web 与插件版本?运行服务会短暂重启。')) return
+  busy.value = true
+  error.value = ''
+  try {
+    await poll(await rollbackSystemUpdate())
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : '无法启动系统回滚'
+  } finally {
+    busy.value = false
+  }
+}
+
+watch(() => props.open, open => {
+  stopPolling()
+  error.value = ''
+  if (open) {
+    job.value = undefined
+    busy.value = true
+    void refresh(false)
+      .then(() => refresh(true))
+      .finally(() => { busy.value = false })
+  }
+})
+
+onBeforeUnmount(stopPolling)
+</script>
+
+<template>
+  <Teleport to="body">
+    <Transition name="system-update-fade">
+      <div v-if="open" class="system-update-layer" role="presentation" @mousedown.self="!active && emit('close')">
+        <section class="system-update-dialog" role="dialog" aria-modal="true" aria-labelledby="system-update-title">
+          <header>
+            <div><small>SYSTEM RELEASE</small><h2 id="system-update-title">系统更新</h2></div>
+            <button class="icon-button" type="button" aria-label="关闭系统更新" :disabled="!!active" @click="emit('close')"><AppIcon name="close" /></button>
+          </header>
+
+          <p class="system-update-intro">Web 服务与 Hermes Dashboard 插件按兼容组合一起更新;用户数据保留在独立目录中。</p>
+          <p v-if="error" class="system-update-error"><AppIcon name="alert" :size="15" />{{ error }}</p>
+
+          <section v-if="status" class="version-grid">
+            <article><small>当前 Web</small><strong>{{ status.current.webVersion }}</strong></article>
+            <article><small>当前插件</small><strong>{{ currentPlugin }}</strong></article>
+            <article><small>最新 Web</small><strong>{{ status.latest?.webVersion || status.current.webVersion }}</strong></article>
+            <article><small>配套插件</small><strong>{{ status.latest?.pluginVersion || status.current.pluginVersion }}</strong></article>
+          </section>
+
+          <p v-if="status && !status.versionsMatch" class="version-warning"><AppIcon name="alert" :size="14" />当前插件与 Web 发布清单不匹配,升级时会恢复为配套组合。</p>
+          <p v-if="status?.installationMode === 'source'" class="mode-note">首次升级会把运行服务迁移到可回滚的版本目录;Git 工作区不会被覆盖。</p>
+          <p v-if="status && !status.supported" class="mode-note">{{ status.unsupportedReason }}</p>
+
+          <section v-if="job" class="update-progress" :class="`update-progress--${job.state}`">
+            <span class="progress-icon"><AppIcon :name="job.state === 'succeeded' || job.state === 'rolled_back' ? 'check' : job.state === 'failed' ? 'alert' : 'refresh'" :size="17" /></span>
+            <span><strong>{{ job.message }}</strong><small v-if="job.error">{{ job.error }}</small></span>
+          </section>
+
+          <footer>
+            <button v-if="job?.state === 'succeeded' || job?.state === 'rolled_back'" class="quiet-button" type="button" @click="reloadPage"><AppIcon name="refresh" :size="15" />刷新页面</button>
+            <button v-else-if="status?.canRollback && !active" class="quiet-button danger" type="button" :disabled="busy" @click="rollback">回滚上一版本</button>
+            <button class="quiet-button" type="button" :disabled="checking || !!active" @click="refresh(true)"><AppIcon name="refresh" :size="15" />{{ checking ? '检查中…' : '检查更新' }}</button>
+            <button class="solid-button" type="button" :disabled="!canApply" @click="applyUpdate"><AppIcon name="download" :size="16" />{{ active ? '升级中…' : status?.updateAvailable ? '升级配套版本' : '已是最新版本' }}</button>
+          </footer>
+        </section>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<style scoped>
+.system-update-layer { position: fixed; z-index: 270; inset: 0; display: grid; place-items: center; padding: 18px; background: var(--scrim); backdrop-filter: blur(6px); }
+.system-update-dialog { width: min(520px, 100%); max-height: min(760px, calc(100vh - 36px)); overflow-y: auto; padding: 19px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
+header { display: flex; align-items: flex-start; justify-content: space-between; } header small { color: var(--text-muted); font-size: 9px; letter-spacing: .08em; } h2 { margin: 3px 0 0; font-size: 19px; letter-spacing: -.03em; }
+.system-update-intro { margin: 17px 0 14px; color: var(--text-secondary); font-size: 11px; line-height: 1.65; }
+.system-update-error, .version-warning { display: flex; align-items: flex-start; gap: 7px; margin: 0 0 13px; padding: 10px; border-radius: 10px; font-size: 10px; line-height: 1.55; }.system-update-error { background: color-mix(in srgb, var(--danger) 9%, transparent); color: var(--danger); }.version-warning { background: color-mix(in srgb, var(--warning, #bd7611) 10%, transparent); color: var(--text-secondary); }
+.version-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }.version-grid article { display: grid; gap: 5px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); }.version-grid small { color: var(--text-muted); font-size: 9px; }.version-grid strong { font-size: 15px; }
+.mode-note { margin: 12px 1px 0; color: var(--text-muted); font-size: 9px; line-height: 1.55; }
+.update-progress { display: flex; gap: 10px; margin-top: 14px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); }.progress-icon { display: grid; width: 30px; height: 30px; flex: 0 0 30px; place-items: center; border-radius: 50%; background: var(--surface-raised); color: var(--text-secondary); }.update-progress > span:last-child { display: grid; gap: 4px; min-width: 0; }.update-progress strong { font-size: 11px; }.update-progress small { color: var(--danger); font-size: 9px; line-height: 1.5; overflow-wrap: anywhere; }.update-progress:not(.update-progress--failed, .update-progress--succeeded, .update-progress--rolled_back) .progress-icon { animation: update-spin 1s linear infinite; }.update-progress--succeeded .progress-icon, .update-progress--rolled_back .progress-icon { color: var(--success); }.update-progress--failed .progress-icon { color: var(--danger); }
+footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); } footer button { gap: 6px; }.danger { margin-right: auto; color: var(--danger); }
+.system-update-fade-enter-active, .system-update-fade-leave-active { transition: opacity 140ms ease; }.system-update-fade-enter-active .system-update-dialog, .system-update-fade-leave-active .system-update-dialog { transition: transform 170ms var(--ease-out); }.system-update-fade-enter-from, .system-update-fade-leave-to { opacity: 0; }.system-update-fade-enter-from .system-update-dialog, .system-update-fade-leave-to .system-update-dialog { transform: translateY(8px) scale(.985); }
+@keyframes update-spin { to { transform: rotate(360deg); } }
+@media (max-width: 540px) { .system-update-layer { place-items: end center; padding: 0; }.system-update-dialog { width: 100%; max-height: calc(100vh - 12px); border-radius: 19px 19px 0 0; padding-bottom: max(18px, env(safe-area-inset-bottom)); }.version-grid { grid-template-columns: 1fr 1fr; } footer .solid-button { flex: 1 1 100%; } }
+</style>

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

@@ -6,6 +6,7 @@ import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import BrandMark from '@/components/common/BrandMark.vue'
 import NodePairingDialog from '@/components/app/NodePairingDialog.vue'
+import SystemUpdateDialog from '@/components/app/SystemUpdateDialog.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 
 type NavItem = {
@@ -62,6 +63,7 @@ const profileMenuOpen = ref(false)
 const sidebarCollapsed = ref(false)
 const sidebarSearchOpen = ref(false)
 const nodePairingOpen = ref(false)
+const systemUpdateOpen = ref(false)
 const desktopSidebarContext = ref<HTMLElement | null>(null)
 const mobileSidebarContext = ref<HTMLElement | null>(null)
 
@@ -287,6 +289,7 @@ onBeforeUnmount(() => {
                 <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="15" />
               </button>
               <button class="profile-menu__edit" type="button" :disabled="!activeProfile" @click="profileMenuOpen = false; emit('editProfile')"><AppIcon name="settings" :size="15" /><strong>编辑当前 Agent</strong></button>
+              <button class="profile-menu__update" type="button" @click="profileMenuOpen = false; systemUpdateOpen = true"><AppIcon name="download" :size="15" /><strong>系统更新</strong></button>
               <button class="profile-menu__logout" type="button" @click="profileMenuOpen = false; emit('logout')">
                 <AppIcon name="logout" :size="15" /><strong>退出登录</strong>
               </button>
@@ -397,6 +400,7 @@ onBeforeUnmount(() => {
                 <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="15" />
               </button>
               <button class="profile-menu__edit" type="button" :disabled="!activeProfile" @click="profileMenuOpen = false; emit('editProfile')"><AppIcon name="settings" :size="15" /><strong>编辑当前 Agent</strong></button>
+              <button class="profile-menu__update" type="button" @click="profileMenuOpen = false; systemUpdateOpen = true; mobileDrawerOpen = false"><AppIcon name="download" :size="15" /><strong>系统更新</strong></button>
               <button class="profile-menu__logout" type="button" @click="profileMenuOpen = false; emit('logout')">
                 <AppIcon name="logout" :size="15" /><strong>退出登录</strong>
               </button>
@@ -422,6 +426,7 @@ onBeforeUnmount(() => {
     </Transition>
 
     <NodePairingDialog :open="nodePairingOpen" :insecure-transport="insecureTransport" :user-name="pairingUserName || userName" @close="nodePairingOpen = false" />
+    <SystemUpdateDialog :open="systemUpdateOpen" @close="systemUpdateOpen = false" />
   </div>
 </template>
 
@@ -581,6 +586,7 @@ onBeforeUnmount(() => {
 .profile-menu button > span:not(.agent-avatar) { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 8px; background: var(--surface-soft); color: var(--text-secondary); font-size: 10px; }
 .profile-menu button strong { flex: 1; overflow: hidden; font-size: 12px; text-overflow: ellipsis; }
 .profile-menu__edit { margin-top: 4px; border-top: 1px solid var(--line) !important; color: var(--text-secondary); }
+.profile-menu__update { color: var(--text-secondary); }
 .profile-menu__logout { margin-top: 4px; border-top: 1px solid var(--line) !important; color: var(--danger); }
 .account-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; }
 .account-copy strong, .account-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

+ 6 - 0
src/server/app.ts

@@ -19,6 +19,7 @@ import {
 } from './security.js'
 import { UpstreamHttpError, UpstreamClient } from './upstream.js'
 import { UploadStore } from './uploads.js'
+import { SystemUpdateManager } from './updateManager.js'
 import { installWebSocketRelay } from './websocket.js'
 
 export interface ApplicationOptions {
@@ -29,6 +30,7 @@ export interface ApplicationOptions {
   pairings?: NodePairingStore
   uploads?: UploadStore
   restartDashboard?: () => Promise<void>
+  updates?: SystemUpdateManager
 }
 
 export interface ApplicationRuntime {
@@ -39,6 +41,7 @@ export interface ApplicationRuntime {
   pairings: NodePairingStore
   upstream: UpstreamClient
   uploads: UploadStore
+  updates: SystemUpdateManager
   close(): void
 }
 
@@ -78,6 +81,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const pairings = options.pairings ?? new NodePairingStore(config.home)
   const upstream = new UpstreamClient(config.upstream, options.fetchImpl, Boolean(config.tlsCert))
   const uploads = options.uploads ?? new UploadStore(config.home)
+  const updates = options.updates ?? new SystemUpdateManager(config)
   try {
     uploads.cleanupUncommitted()
   } catch {
@@ -146,6 +150,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     leases,
     pairings,
     uploads,
+    updates,
     restartDashboard: options.restartDashboard,
   })
   app.use(router.routes())
@@ -168,6 +173,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     pairings,
     upstream,
     uploads,
+    updates,
     close: () => uploads.close(),
   }
 }

+ 26 - 0
src/server/config.ts

@@ -19,9 +19,13 @@ export interface ServerConfig {
   production: boolean
   superviseDashboard?: boolean
   yaoyaoPluginSource?: string
+  releaseSource?: string
+  releaseRoot?: string
+  allowRemoteUpdate?: boolean
 }
 
 export const DEFAULT_YAOYAO_PLUGIN_SOURCE = 'https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao'
+export const DEFAULT_YAOYAO_RELEASE_SOURCE = 'https://git.samien.cn/samien/hermes-yaoyao.git'
 
 function flag(value: string | undefined): boolean {
   return value === '1' || value?.toLowerCase() === 'true'
@@ -63,6 +67,22 @@ function parsePluginSource(value: string | undefined): string {
   return source
 }
 
+function parseReleaseSource(value: string | undefined): string {
+  const source = value?.trim() || DEFAULT_YAOYAO_RELEASE_SOURCE
+  if (source.length > 2_048 || /[\u0000-\u001f\u007f]/.test(source)) {
+    throw new Error('HERMES_YAOYAO_RELEASE_SOURCE is invalid')
+  }
+  if (source.startsWith('https://')) {
+    const url = new URL(source)
+    if (url.username || url.password || url.search || url.hash) {
+      throw new Error('HERMES_YAOYAO_RELEASE_SOURCE must not contain credentials, query, or fragment')
+    }
+  } else if (!source.startsWith('git@') && !source.startsWith('ssh://')) {
+    throw new Error('HERMES_YAOYAO_RELEASE_SOURCE must be an HTTPS or SSH Git source')
+  }
+  return source
+}
+
 function normalizeConfiguredHost(raw: string): string {
   const value = raw.trim().toLowerCase()
   if (!value) return ''
@@ -119,6 +139,9 @@ export function loadServerConfig(
   const production = env.NODE_ENV === 'production'
   const superviseDashboard = flag(env.HERMES_YAOYAO_SUPERVISE_DASHBOARD)
   const yaoyaoPluginSource = parsePluginSource(env.HERMES_YAOYAO_PLUGIN_SOURCE)
+  const releaseSource = parseReleaseSource(env.HERMES_YAOYAO_RELEASE_SOURCE)
+  const releaseRoot = resolve(env.HERMES_YAOYAO_RELEASE_ROOT?.trim() || `${homedir()}/.local/share/hermes-yaoyao`)
+  const allowRemoteUpdate = flag(env.HERMES_YAOYAO_ALLOW_REMOTE_UPDATE)
   const insecureLan = !tlsCert && !isLoopbackHost(host)
   if (production && insecureLan && !allowInsecureLan) {
     throw new Error(
@@ -148,5 +171,8 @@ export function loadServerConfig(
     production,
     superviseDashboard,
     yaoyaoPluginSource,
+    releaseSource,
+    releaseRoot,
+    allowRemoteUpdate,
   }
 }

+ 55 - 0
src/server/releases.ts

@@ -0,0 +1,55 @@
+import { readFileSync } from 'node:fs'
+
+export interface ReleaseManifest {
+  schemaVersion: 1
+  releaseVersion: string
+  webVersion: string
+  pluginVersion: string
+  gitTag: string
+}
+
+const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
+
+function version(value: unknown, field: string): string {
+  if (typeof value !== 'string' || !VERSION_PATTERN.test(value)) {
+    throw new Error(`${field} 必须是有效的语义版本`)
+  }
+  return value
+}
+
+export function parseReleaseManifest(value: unknown): ReleaseManifest {
+  if (!value || typeof value !== 'object' || Array.isArray(value)) {
+    throw new Error('release.json 必须是对象')
+  }
+  const input = value as Record<string, unknown>
+  if (input.schemaVersion !== 1) throw new Error('release.json schemaVersion 必须为 1')
+  const releaseVersion = version(input.releaseVersion, 'releaseVersion')
+  const webVersion = version(input.webVersion, 'webVersion')
+  const pluginVersion = version(input.pluginVersion, 'pluginVersion')
+  const gitTag = typeof input.gitTag === 'string' ? input.gitTag : ''
+  if (releaseVersion !== webVersion) throw new Error('releaseVersion 与 webVersion 必须一致')
+  if (gitTag !== `v${releaseVersion}`) throw new Error('gitTag 必须与 releaseVersion 一致')
+  return { schemaVersion: 1, releaseVersion, webVersion, pluginVersion, gitTag }
+}
+
+export function readReleaseManifest(path: string): ReleaseManifest {
+  return parseReleaseManifest(JSON.parse(readFileSync(path, 'utf8')))
+}
+
+function numericCore(value: string): [number, number, number] {
+  const [major = 0, minor = 0, patch = 0] = value.split('-', 1)[0]!.split('.').map(Number)
+  return [major, minor, patch]
+}
+
+export function compareReleaseVersions(left: string, right: string): number {
+  const a = numericCore(left)
+  const b = numericCore(right)
+  for (let index = 0; index < 3; index += 1) {
+    if (a[index] !== b[index]) return a[index]! - b[index]!
+  }
+  const leftSuffix = left.includes('-') ? left.slice(left.indexOf('-') + 1) : ''
+  const rightSuffix = right.includes('-') ? right.slice(right.indexOf('-') + 1) : ''
+  if (!leftSuffix && rightSuffix) return 1
+  if (leftSuffix && !rightSuffix) return -1
+  return leftSuffix.localeCompare(rightSuffix)
+}

+ 125 - 22
src/server/routes.ts

@@ -6,7 +6,7 @@ import { basename, resolve, sep } from 'node:path'
 import { lookup as mimeLookup } from 'mime-types'
 import { isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '../shared/types.js'
 import type { ServerConfig } from './config.js'
-import { DEFAULT_YAOYAO_PLUGIN_SOURCE, isLoopbackUpstream } from './config.js'
+import { DEFAULT_YAOYAO_PLUGIN_SOURCE, isLoopbackHost, isLoopbackUpstream } from './config.js'
 import { HttpError } from './errors.js'
 import { canonicalEpoch, groupCursor, type RealtimeChannel, RealtimeLeaseStore } from './leases.js'
 import {
@@ -26,6 +26,7 @@ import {
   UpstreamClient,
 } from './upstream.js'
 import { receiveGroupUploads, uploadMarkdown, UploadStore } from './uploads.js'
+import { SystemUpdateManager } from './updateManager.js'
 
 type JsonObject = Record<string, unknown>
 
@@ -36,6 +37,7 @@ export interface RouteDependencies {
   leases: RealtimeLeaseStore
   pairings: NodePairingStore
   uploads: UploadStore
+  updates: SystemUpdateManager
   restartDashboard?: () => Promise<void>
 }
 
@@ -169,6 +171,68 @@ function publicStatus(status: JsonObject): JsonObject {
   }
 }
 
+async function yaoyaoPluginVersion(
+  dependencies: RouteDependencies,
+  jar: CookieJar,
+): Promise<string | undefined> {
+  const response = await dependencies.upstream.request('/api/dashboard/plugins', jar)
+  if (response.status < 200 || response.status >= 300) return undefined
+  const manifests = parseJsonValue(response)
+  if (!Array.isArray(manifests)) return undefined
+  const manifest = manifests.find(entry => (
+    entry && typeof entry === 'object' && !Array.isArray(entry)
+    && (entry as JsonObject).name === 'yaoyao'
+  )) as JsonObject | undefined
+  return typeof manifest?.version === 'string' ? manifest.version : undefined
+}
+
+async function requireYaoyaoStorageReady(
+  dependencies: RouteDependencies,
+  jar: CookieJar,
+  installedVersion?: string,
+): Promise<void> {
+  if (!installedVersion) return
+  const response = await dependencies.upstream.request(
+    '/api/plugins/yaoyao/maintenance/storage',
+    jar,
+  )
+  if (response.status < 200 || response.status >= 300) {
+    throw new HttpError(
+      409,
+      '当前夭夭插件尚未具备安全升级能力,请先按文档完成一次兼容版本安装',
+      'yaoyao_storage_migration_required',
+    )
+  }
+  const storage = parseJson(response)
+  if (storage.ready !== true) {
+    throw new HttpError(
+      409,
+      '检测到旧数据目录冲突,已停止升级以避免覆盖夭夭数据',
+      'yaoyao_storage_conflict',
+    )
+  }
+}
+
+function requireLocalSystemUpdate(ctx: Koa.Context, dependencies: RouteDependencies): void {
+  if (dependencies.config.allowRemoteUpdate) return
+  const address = (ctx.req.socket.remoteAddress ?? '').replace(/^::ffff:/, '')
+  if (!isLoopbackHost(address)) {
+    throw new HttpError(
+      403,
+      '系统升级默认只允许在本机执行',
+      'remote_system_update_disabled',
+    )
+  }
+}
+
+function updateFailure(error: unknown): HttpError {
+  const message = error instanceof Error ? error.message : String(error)
+  if (/最新版本|目标版本|正在执行|可回滚|不支持/.test(message)) {
+    return new HttpError(409, message, 'system_update_conflict')
+  }
+  return new HttpError(502, `无法访问系统发布源:${message}`, 'system_update_source_failed')
+}
+
 async function withJar<T>(ctx: Koa.Context, run: (jar: CookieJar) => Promise<T>): Promise<T> {
   const jar = new CookieJar(ctx.get('cookie'))
   try {
@@ -857,6 +921,61 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       }
     })
   })
+  router.get('/api/app/system/update/status', async (ctx) => {
+    await withJar(ctx, async (jar) => {
+      await requireGatewayAuthentication(ctx, dependencies, jar)
+      const installedPluginVersion = await yaoyaoPluginVersion(dependencies, jar)
+      json(ctx, 200, dependencies.updates.status(installedPluginVersion))
+    })
+  })
+  router.post('/api/app/system/update/check', async (ctx) => {
+    await withJar(ctx, async (jar) => {
+      await requireGatewayAuthentication(ctx, dependencies, jar)
+      const installedPluginVersion = await yaoyaoPluginVersion(dependencies, jar)
+      try {
+        json(ctx, 200, await dependencies.updates.check(installedPluginVersion))
+      } catch (error) {
+        throw updateFailure(error)
+      }
+    })
+  })
+  router.post('/api/app/system/update/apply', async (ctx) => {
+    await withJar(ctx, async (jar) => {
+      await requireGatewayAuthentication(ctx, dependencies, jar)
+      requireLocalSystemUpdate(ctx, dependencies)
+      const request = body(ctx)
+      const targetVersion = typeof request.targetVersion === 'string' ? request.targetVersion.trim() : ''
+      if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(targetVersion)) {
+        throw new HttpError(400, 'targetVersion 必须是有效的发布版本', 'invalid_request')
+      }
+      const installedPluginVersion = await yaoyaoPluginVersion(dependencies, jar)
+      await requireYaoyaoStorageReady(dependencies, jar, installedPluginVersion)
+      try {
+        json(ctx, 202, await dependencies.updates.startUpdate(targetVersion, installedPluginVersion))
+      } catch (error) {
+        throw updateFailure(error)
+      }
+    })
+  })
+  router.get('/api/app/system/update/jobs/:jobID', async (ctx) => {
+    await withJar(ctx, async (jar) => {
+      await requireGatewayAuthentication(ctx, dependencies, jar)
+      const job = dependencies.updates.job(ctx.params.jobID)
+      if (!job) throw new HttpError(404, '升级任务不存在', 'system_update_job_not_found')
+      json(ctx, 200, job)
+    })
+  })
+  router.post('/api/app/system/update/rollback', async (ctx) => {
+    await withJar(ctx, async (jar) => {
+      await requireGatewayAuthentication(ctx, dependencies, jar)
+      requireLocalSystemUpdate(ctx, dependencies)
+      try {
+        json(ctx, 202, dependencies.updates.startRollback())
+      } catch (error) {
+        throw updateFailure(error)
+      }
+    })
+  })
   router.post('/api/app/plugins/yaoyao/install', async (ctx) => {
     await withJar(ctx, async (jar) => {
       await requireGatewayAuthentication(ctx, dependencies, jar)
@@ -884,27 +1003,11 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
         && (entry as JsonObject).name === 'yaoyao'
       ))
 
-      if (installed) {
-        const storageResponse = await dependencies.upstream.request(
-          '/api/plugins/yaoyao/maintenance/storage',
-          jar,
-        )
-        if (storageResponse.status < 200 || storageResponse.status >= 300) {
-          throw new HttpError(
-            409,
-            '当前夭夭插件尚未具备安全升级能力,请先按文档完成一次兼容版本安装',
-            'yaoyao_storage_migration_required',
-          )
-        }
-        const storage = parseJson(storageResponse)
-        if (storage.ready !== true) {
-          throw new HttpError(
-            409,
-            '检测到旧数据目录冲突,已停止安装以避免覆盖夭夭数据',
-            'yaoyao_storage_conflict',
-          )
-        }
-      }
+      await requireYaoyaoStorageReady(
+        dependencies,
+        jar,
+        installed ? 'installed' : undefined,
+      )
 
       const force = request.force === true
       if (installed && !force) {

+ 318 - 0
src/server/updateManager.ts

@@ -0,0 +1,318 @@
+import { execFile } from 'node:child_process'
+import { randomUUID } from 'node:crypto'
+import {
+  closeSync,
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  openSync,
+  readFileSync,
+  rmSync,
+  statSync,
+  writeFileSync,
+} from 'node:fs'
+import { homedir, tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { spawn } from 'node:child_process'
+import type { ServerConfig } from './config.js'
+import { DEFAULT_YAOYAO_RELEASE_SOURCE } from './config.js'
+import {
+  compareReleaseVersions,
+  readReleaseManifest,
+  type ReleaseManifest,
+} from './releases.js'
+
+export type UpdateJobState =
+  | 'queued'
+  | 'downloading'
+  | 'building'
+  | 'installing'
+  | 'restarting'
+  | 'verifying'
+  | 'rolling_back'
+  | 'succeeded'
+  | 'failed'
+  | 'rolled_back'
+
+export interface UpdateJob {
+  id: string
+  operation: 'update' | 'rollback'
+  state: UpdateJobState
+  message: string
+  createdAt: string
+  updatedAt: string
+  target?: ReleaseManifest
+  error?: string
+}
+
+interface UpdatePlan {
+  source: string
+  releaseRoot: string
+  dataHome: string
+  hermesHome: string
+  previousServiceRoot: string
+  target?: ReleaseManifest
+  targetCommit?: string
+}
+
+interface StoredUpdateJob extends UpdateJob {
+  plan: UpdatePlan
+}
+
+export interface SystemUpdateStatus {
+  current: ReleaseManifest
+  installedPluginVersion?: string
+  versionsMatch: boolean
+  installationMode: 'source' | 'release'
+  latest?: ReleaseManifest
+  updateAvailable: boolean
+  supported: boolean
+  unsupportedReason?: string
+  canRollback: boolean
+  job?: UpdateJob
+}
+
+export interface RemoteRelease {
+  manifest: ReleaseManifest
+  commit: string
+}
+
+type InspectRemote = (source: string, current: ReleaseManifest) => Promise<RemoteRelease | undefined>
+type LaunchUpdater = (jobPath: string) => number | undefined | void
+
+export interface SystemUpdateManagerOptions {
+  projectRoot?: string
+  manifestPath?: string
+  updaterPath?: string
+  inspectRemote?: InspectRemote
+  launchUpdater?: LaunchUpdater
+  platform?: NodeJS.Platform
+}
+
+function run(command: string, args: string[], timeout = 30_000): Promise<string> {
+  return new Promise((resolvePromise, reject) => {
+    execFile(command, args, { encoding: 'utf8', timeout, maxBuffer: 4 * 1_024 * 1_024 }, (error, stdout) => {
+      if (error) reject(error)
+      else resolvePromise(stdout)
+    })
+  })
+}
+
+export async function inspectGitRemote(source: string, current: ReleaseManifest): Promise<RemoteRelease | undefined> {
+  const output = await run('git', ['ls-remote', '--tags', source, 'refs/tags/v*'])
+  const byVersion = new Map<string, { version: string; commit: string; peeled: boolean }>()
+  for (const line of output.split('\n')) {
+    const [commit = '', reference = ''] = line.trim().split(/\s+/, 2)
+    const match = reference.match(/^refs\/tags\/v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(\^\{\})?$/)
+    if (!match || !/^[0-9a-f]{40,64}$/.test(commit)) continue
+    const version = match[1]!
+    const peeled = Boolean(match[2])
+    const existing = byVersion.get(version)
+    if (!existing || peeled) byVersion.set(version, { version, commit, peeled })
+  }
+  const releases = [...byVersion.values()]
+    .sort((left, right) => compareReleaseVersions(left.version, right.version))
+  const latest = releases.at(-1)
+  if (!latest || compareReleaseVersions(latest.version, current.releaseVersion) <= 0) return undefined
+
+  const temporary = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-update-check-'))
+  try {
+    await run('git', ['clone', '--quiet', '--depth', '1', '--single-branch', '--branch', `v${latest.version}`, source, temporary], 60_000)
+    const commit = (await run('git', ['-C', temporary, 'rev-parse', 'HEAD'])).trim()
+    if (commit !== latest.commit) throw new Error('远端发布标签在检查期间发生变化')
+    const manifest = readReleaseManifest(join(temporary, 'release.json'))
+    if (manifest.releaseVersion !== latest.version) throw new Error('远端标签与 release.json 版本不一致')
+    return { manifest, commit }
+  } finally {
+    rmSync(temporary, { recursive: true, force: true })
+  }
+}
+
+function publicJob(job: StoredUpdateJob | undefined): UpdateJob | undefined {
+  if (!job) return undefined
+  const { plan: _plan, ...value } = job
+  return value
+}
+
+function terminal(state: UpdateJobState): boolean {
+  return ['succeeded', 'failed', 'rolled_back'].includes(state)
+}
+
+export class SystemUpdateManager {
+  private readonly projectRoot: string
+  private readonly manifestPath: string
+  private readonly updaterPath: string
+  private readonly updateHome: string
+  private readonly releaseRoot: string
+  private readonly releaseSource: string
+  private readonly inspectRemote: InspectRemote
+  private readonly launchUpdater: LaunchUpdater
+  private readonly platform: NodeJS.Platform
+
+  constructor(private readonly config: ServerConfig, options: SystemUpdateManagerOptions = {}) {
+    this.projectRoot = resolve(options.projectRoot ?? process.cwd())
+    this.manifestPath = resolve(options.manifestPath ?? join(this.projectRoot, 'release.json'))
+    this.updaterPath = resolve(options.updaterPath ?? join(this.projectRoot, 'bin', 'hermes-yaoyao-updater.mjs'))
+    this.updateHome = join(config.home, 'updates')
+    this.releaseRoot = resolve(config.releaseRoot ?? join(homedir(), '.local', 'share', 'hermes-yaoyao'))
+    this.releaseSource = config.releaseSource ?? DEFAULT_YAOYAO_RELEASE_SOURCE
+    this.inspectRemote = options.inspectRemote ?? inspectGitRemote
+    this.platform = options.platform ?? process.platform
+    this.launchUpdater = options.launchUpdater ?? ((jobPath) => {
+      const child = spawn(process.execPath, [this.updaterPath, 'run', '--job', jobPath], {
+        detached: true,
+        stdio: 'ignore',
+        env: process.env,
+      })
+      child.unref()
+      return child.pid
+    })
+  }
+
+  currentManifest(): ReleaseManifest {
+    return readReleaseManifest(this.manifestPath)
+  }
+
+  private latestJobID(): string | undefined {
+    try {
+      const value = JSON.parse(readFileSync(join(this.updateHome, 'latest.json'), 'utf8')) as { id?: unknown }
+      return typeof value.id === 'string' ? value.id : undefined
+    } catch {
+      return undefined
+    }
+  }
+
+  private storedJob(id: string): StoredUpdateJob | undefined {
+    if (!/^[0-9a-f-]{36}$/.test(id)) return undefined
+    try {
+      return JSON.parse(readFileSync(join(this.updateHome, `${id}.json`), 'utf8')) as StoredUpdateJob
+    } catch {
+      return undefined
+    }
+  }
+
+  job(id: string): UpdateJob | undefined {
+    return publicJob(this.storedJob(id))
+  }
+
+  private latestJob(): StoredUpdateJob | undefined {
+    const id = this.latestJobID()
+    return id ? this.storedJob(id) : undefined
+  }
+
+  status(installedPluginVersion?: string, latest?: ReleaseManifest): SystemUpdateStatus {
+    const current = this.currentManifest()
+    const job = this.latestJob()
+    return {
+      current,
+      installedPluginVersion,
+      versionsMatch: installedPluginVersion === current.pluginVersion,
+      installationMode: this.projectRoot.startsWith(`${this.releaseRoot}/`) ? 'release' : 'source',
+      latest,
+      updateAvailable: Boolean(latest && compareReleaseVersions(latest.releaseVersion, current.releaseVersion) > 0),
+      supported: this.platform === 'darwin',
+      unsupportedReason: this.platform === 'darwin' ? undefined : '容器和非 macOS 环境请通过替换部署镜像升级',
+      canRollback: existsSync(join(this.updateHome, 'last-success.json')),
+      job: publicJob(job),
+    }
+  }
+
+  async check(installedPluginVersion?: string): Promise<SystemUpdateStatus> {
+    const current = this.currentManifest()
+    const latest = await this.inspectRemote(this.releaseSource, current)
+    return this.status(installedPluginVersion, latest?.manifest)
+  }
+
+  private acquire(jobID: string): void {
+    mkdirSync(this.updateHome, { recursive: true, mode: 0o700 })
+    const lockPath = join(this.updateHome, 'active.lock')
+    try {
+      const descriptor = openSync(lockPath, 'wx', 0o600)
+      writeFileSync(descriptor, `${JSON.stringify({ jobID })}\n`)
+      closeSync(descriptor)
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
+      let lock: { jobID?: unknown; pid?: unknown } = {}
+      try { lock = JSON.parse(readFileSync(lockPath, 'utf8')) as typeof lock } catch { /* legacy lock */ }
+      if (typeof lock.pid === 'number') {
+        try {
+          process.kill(lock.pid, 0)
+          throw new Error('已有系统更新正在执行')
+        } catch (processError) {
+          if ((processError as NodeJS.ErrnoException).code !== 'ESRCH') throw processError
+        }
+      } else if (Date.now() - statSync(lockPath).mtimeMs < 5 * 60_000) {
+        throw new Error('已有系统更新正在执行')
+      }
+      const latest = this.latestJob()
+      if (latest && !terminal(latest.state)) {
+        const failed = {
+          ...latest,
+          state: 'failed' as const,
+          message: '升级进程意外终止,可重新检查更新',
+          error: '升级进程已不存在',
+          updatedAt: new Date().toISOString(),
+        }
+        writeFileSync(join(this.updateHome, `${latest.id}.json`), `${JSON.stringify(failed, null, 2)}\n`, { mode: 0o600 })
+      }
+      rmSync(lockPath, { force: true })
+      const descriptor = openSync(lockPath, 'wx', 0o600)
+      writeFileSync(descriptor, `${JSON.stringify({ jobID })}\n`)
+      closeSync(descriptor)
+    }
+  }
+
+  private createJob(operation: UpdateJob['operation'], target?: ReleaseManifest, targetCommit?: string): StoredUpdateJob {
+    const id = randomUUID()
+    this.acquire(id)
+    const now = new Date().toISOString()
+    const job: StoredUpdateJob = {
+      id,
+      operation,
+      state: 'queued',
+      message: operation === 'update' ? '升级任务已排队' : '回滚任务已排队',
+      createdAt: now,
+      updatedAt: now,
+      target,
+      plan: {
+        source: this.releaseSource,
+        releaseRoot: this.releaseRoot,
+        dataHome: this.config.home,
+        hermesHome: resolve(process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')),
+        previousServiceRoot: this.projectRoot,
+        target,
+        targetCommit,
+      },
+    }
+    const jobPath = join(this.updateHome, `${id}.json`)
+    writeFileSync(jobPath, `${JSON.stringify(job, null, 2)}\n`, { mode: 0o600 })
+    writeFileSync(join(this.updateHome, 'latest.json'), `${JSON.stringify({ id })}\n`, { mode: 0o600 })
+    try {
+      const pid = this.launchUpdater(jobPath)
+      if (typeof pid === 'number' && existsSync(join(this.updateHome, 'active.lock'))) {
+        writeFileSync(join(this.updateHome, 'active.lock'), `${JSON.stringify({ jobID: id, pid })}\n`, { mode: 0o600 })
+      }
+    } catch (error) {
+      rmSync(join(this.updateHome, 'active.lock'), { force: true })
+      throw error
+    }
+    return job
+  }
+
+  async startUpdate(targetVersion: string, installedPluginVersion?: string): Promise<UpdateJob> {
+    if (this.platform !== 'darwin') throw new Error('当前环境不支持服务内升级')
+    const current = this.currentManifest()
+    const latest = await this.inspectRemote(this.releaseSource, current)
+    if (!latest || compareReleaseVersions(latest.manifest.releaseVersion, current.releaseVersion) <= 0) {
+      throw new Error('当前已经是最新版本')
+    }
+    if (targetVersion !== latest.manifest.releaseVersion) throw new Error('目标版本不是当前发布源的最新版本')
+    return publicJob(this.createJob('update', latest.manifest, latest.commit))!
+  }
+
+  startRollback(): UpdateJob {
+    if (this.platform !== 'darwin') throw new Error('当前环境不支持服务内回滚')
+    if (!existsSync(join(this.updateHome, 'last-success.json'))) throw new Error('没有可回滚的上一版本')
+    return publicJob(this.createJob('rollback'))!
+  }
+}

+ 47 - 0
tests/client/systemUpdate.test.ts

@@ -0,0 +1,47 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+  applySystemUpdate,
+  checkSystemUpdate,
+  rollbackSystemUpdate,
+  systemUpdateJob,
+  systemUpdateStatus,
+} from '@/api/systemUpdate'
+import { setApiCsrfToken } from '@/api/client'
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+  setApiCsrfToken('')
+})
+
+describe('system update client protocol', () => {
+  it('uses authenticated app routes and protects mutations with CSRF', async () => {
+    const calls: Array<{ path: string; init?: RequestInit }> = []
+    vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
+      calls.push({ path, init })
+      return new Response(JSON.stringify({
+        current: { schemaVersion: 1, releaseVersion: '0.2.0', webVersion: '0.2.0', pluginVersion: '1.7.1', gitTag: 'v0.2.0' },
+        versionsMatch: true,
+        installationMode: 'source',
+        updateAvailable: false,
+      }), { status: 200, headers: { 'content-type': 'application/json' } })
+    }))
+    setApiCsrfToken('csrf-system-update')
+
+    await systemUpdateStatus()
+    await checkSystemUpdate()
+    await applySystemUpdate('0.3.0')
+    await systemUpdateJob('11111111-1111-4111-8111-111111111111')
+    await rollbackSystemUpdate()
+
+    expect(calls.map(call => call.path)).toEqual([
+      '/api/app/system/update/status',
+      '/api/app/system/update/check',
+      '/api/app/system/update/apply',
+      '/api/app/system/update/jobs/11111111-1111-4111-8111-111111111111',
+      '/api/app/system/update/rollback',
+    ])
+    expect(new Headers(calls[1]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-system-update')
+    expect(calls[2]!.init?.body).toBe(JSON.stringify({ targetVersion: '0.3.0' }))
+    expect(new Headers(calls[4]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-system-update')
+  })
+})

+ 11 - 0
tests/server/security.test.ts

@@ -110,6 +110,8 @@ describe('server security boundary', () => {
     expect(value.yaoyaoPluginSource).toBe(
       'https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao',
     )
+    expect(value.releaseSource).toBe('https://git.samien.cn/samien/hermes-yaoyao.git')
+    expect(value.allowRemoteUpdate).toBe(false)
   })
 
   it('rejects credentials embedded in the configured Yaoyao plugin source', () => {
@@ -117,4 +119,13 @@ describe('server security boundary', () => {
       HERMES_YAOYAO_PLUGIN_SOURCE: 'https://user:secret@git.example/yaoyao.git#hermes-plugins/yaoyao',
     })).toThrow(/must not contain credentials/)
   })
+
+  it('requires a credential-free HTTPS or SSH system release source', () => {
+    expect(() => loadServerConfig({
+      HERMES_YAOYAO_RELEASE_SOURCE: 'http://git.example/hermes-yaoyao.git',
+    })).toThrow(/HTTPS or SSH/)
+    expect(() => loadServerConfig({
+      HERMES_YAOYAO_RELEASE_SOURCE: 'https://user:secret@git.example/hermes-yaoyao.git',
+    })).toThrow(/must not contain credentials/)
+  })
 })

+ 94 - 0
tests/server/systemUpdateRoutes.test.ts

@@ -0,0 +1,94 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import request from 'supertest'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
+import type { ServerConfig } from '../../src/server/config.js'
+import { SystemUpdateManager } from '../../src/server/updateManager.js'
+
+const runtimes: ApplicationRuntime[] = []
+const roots: string[] = []
+afterEach(() => {
+  for (const runtime of runtimes.splice(0)) runtime.close()
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function cookieHeader(response: request.Response): string {
+  const values = response.headers['set-cookie'] as unknown as string[]
+  return values.map(value => value.split(';', 1)[0]).join('; ')
+}
+
+function json(value: unknown, init: ResponseInit = {}) {
+  return new Response(JSON.stringify(value), {
+    ...init,
+    headers: { 'content-type': 'application/json', ...init.headers },
+  })
+}
+
+describe('system update routes', () => {
+  it('checks and queues a fixed Web/plugin release through authenticated CSRF routes', async () => {
+    const root = mkdtempSync(join(tmpdir(), 'hermes-system-update-routes-'))
+    roots.push(root)
+    const projectRoot = join(root, 'project')
+    mkdirSync(projectRoot)
+    writeFileSync(join(projectRoot, 'release.json'), JSON.stringify({
+      schemaVersion: 1, releaseVersion: '0.2.0', webVersion: '0.2.0', pluginVersion: '1.7.1', gitTag: 'v0.2.0',
+    }))
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(), home: join(root, 'data'), mediaRoot: join(root, 'media'),
+      attachmentsRoot: join(root, 'attachments'), imagesRoot: join(root, 'images'), mediaOwner: 'tester',
+      allowInsecureLan: false, insecureLan: false, production: false,
+      releaseRoot: join(root, 'installed'), releaseSource: 'https://example.test/hermes-yaoyao.git',
+    }
+    const launched: string[] = []
+    const latest = {
+      schemaVersion: 1 as const, releaseVersion: '0.3.0', webVersion: '0.3.0', pluginVersion: '1.8.0', gitTag: 'v0.3.0',
+    }
+    const updates = new SystemUpdateManager(config, {
+      projectRoot,
+      inspectRemote: async () => ({ manifest: latest, commit: 'b'.repeat(40) }),
+      launchUpdater: path => { launched.push(path) },
+      platform: 'darwin',
+    })
+    const fetchImpl = (async (input: string | URL | Request) => {
+      const path = new URL(input instanceof Request ? input.url : String(input)).pathname
+      if (path === '/api/status') return json({ auth_required: true }, { headers: { 'set-cookie': 'hermes_session_at=session; Path=/; HttpOnly' } })
+      if (path === '/api/auth/me') return json({ user_id: 'admin', display_name: '管理员' })
+      if (path === '/api/profiles') return json({ profiles: [{ name: 'default', is_default: true }] })
+      if (path === '/api/plugins/yaoyao/profiles') return json({ profiles: [] })
+      if (path === '/api/dashboard/plugins') return json([{ name: 'yaoyao', version: '1.7.1' }])
+      if (path === '/api/plugins/yaoyao/maintenance/storage') return json({ ready: true })
+      return json({ ok: true })
+    }) as typeof fetch
+    const runtime = createApplication({ config, fetchImpl, updates })
+    runtimes.push(runtime)
+    const bootstrap = await request(runtime.app.callback())
+      .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const cookie = cookieHeader(bootstrap)
+    const mutation = (path: string) => request(runtime.app.callback())
+      .post(path)
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Cookie', cookie)
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+
+    const status = await request(runtime.app.callback())
+      .get('/api/app/system/update/status').set('Host', '127.0.0.1:8800').set('Cookie', cookie).expect(200)
+    expect(status.body).toMatchObject({ installedPluginVersion: '1.7.1', versionsMatch: true, updateAvailable: false })
+
+    const check = await mutation('/api/app/system/update/check').send({}).expect(200)
+    expect(check.body).toMatchObject({ latest, updateAvailable: true })
+
+    const queued = await mutation('/api/app/system/update/apply').send({ targetVersion: '0.3.0' }).expect(202)
+    expect(queued.body).toMatchObject({ operation: 'update', state: 'queued', target: latest })
+    expect(launched).toHaveLength(1)
+
+    const jobResponse = await request(runtime.app.callback())
+      .get(`/api/app/system/update/jobs/${queued.body.id}`)
+      .set('Host', '127.0.0.1:8800').set('Cookie', cookie)
+      .expect(200)
+    expect(jobResponse.body).toMatchObject({ id: queued.body.id, state: 'queued' })
+  })
+})

+ 106 - 0
tests/server/updateManager.test.ts

@@ -0,0 +1,106 @@
+import { execFileSync } from 'node:child_process'
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { ServerConfig } from '../../src/server/config.js'
+import { compareReleaseVersions, parseReleaseManifest } from '../../src/server/releases.js'
+import { inspectGitRemote, SystemUpdateManager } from '../../src/server/updateManager.js'
+
+const roots: string[] = []
+const current = {
+  schemaVersion: 1 as const,
+  releaseVersion: '0.2.0',
+  webVersion: '0.2.0',
+  pluginVersion: '1.7.1',
+  gitTag: 'v0.2.0',
+}
+const latest = {
+  schemaVersion: 1 as const,
+  releaseVersion: '0.3.0',
+  webVersion: '0.3.0',
+  pluginVersion: '1.8.0',
+  gitTag: 'v0.3.0',
+}
+const latestRemote = { manifest: latest, commit: 'a'.repeat(40) }
+
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function fixture() {
+  const root = mkdtempSync(join(tmpdir(), 'hermes-update-manager-'))
+  roots.push(root)
+  writeFileSync(join(root, 'release.json'), JSON.stringify(current))
+  const config: ServerConfig = {
+    host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+    allowedHosts: new Set(), home: join(root, 'data'), mediaRoot: join(root, 'media'),
+    attachmentsRoot: join(root, 'attachments'), imagesRoot: join(root, 'images'),
+    mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
+    releaseRoot: join(root, 'installed'), releaseSource: 'https://example.test/hermes-yaoyao.git',
+  }
+  const launched: string[] = []
+  const manager = new SystemUpdateManager(config, {
+    projectRoot: root,
+    inspectRemote: async () => latestRemote,
+    launchUpdater: path => { launched.push(path) },
+    platform: 'darwin',
+  })
+  return { root, config, manager, launched }
+}
+
+describe('system release contract', () => {
+  it('validates paired versions and semantic ordering', () => {
+    expect(parseReleaseManifest(current)).toEqual(current)
+    expect(compareReleaseVersions('0.3.0', '0.2.9')).toBeGreaterThan(0)
+    expect(compareReleaseVersions('1.0.0', '1.0.0-beta.1')).toBeGreaterThan(0)
+    expect(() => parseReleaseManifest({ ...current, pluginVersion: 'latest' })).toThrow('pluginVersion')
+  })
+
+  it('locks an annotated remote tag to its peeled Git commit', async () => {
+    const root = mkdtempSync(join(tmpdir(), 'hermes-update-remote-'))
+    roots.push(root)
+    const repository = join(root, 'repository')
+    execFileSync('git', ['init', '-q', repository])
+    writeFileSync(join(repository, 'release.json'), JSON.stringify(latest))
+    execFileSync('git', ['-C', repository, 'add', 'release.json'])
+    execFileSync('git', ['-C', repository, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'release'])
+    execFileSync('git', ['-C', repository, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'tag', '-am', 'release', 'v0.3.0'])
+    const commit = execFileSync('git', ['-C', repository, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()
+
+    await expect(inspectGitRemote(repository, current)).resolves.toEqual({ manifest: latest, commit })
+  })
+})
+
+describe('SystemUpdateManager', () => {
+  it('reports the current Web/plugin pair and discovers a newer fixed release', async () => {
+    const { manager } = fixture()
+    const status = await manager.check('1.7.1')
+
+    expect(status.current).toEqual(current)
+    expect(status.latest).toEqual(latest)
+    expect(status.updateAvailable).toBe(true)
+    expect(status.versionsMatch).toBe(true)
+    expect(status.installationMode).toBe('source')
+  })
+
+  it('creates a durable updater job without exposing its internal plan', async () => {
+    const { manager, launched } = fixture()
+    const job = await manager.startUpdate('0.3.0', '1.7.1')
+
+    expect(job).toMatchObject({ operation: 'update', state: 'queued', target: latest })
+    expect(launched).toHaveLength(1)
+    expect(existsSync(launched[0]!)).toBe(true)
+    const stored = JSON.parse(readFileSync(launched[0]!, 'utf8')) as Record<string, unknown>
+    expect(stored.plan).toBeTruthy()
+    expect(manager.job(job.id)).not.toHaveProperty('plan')
+    await expect(manager.startUpdate('0.3.0', '1.7.1')).rejects.toThrow('正在执行')
+  })
+
+  it('rejects arbitrary target versions and rollback without a recovery record', async () => {
+    const { manager } = fixture()
+
+    await expect(manager.startUpdate('9.9.9', '1.7.1')).rejects.toThrow('不是当前发布源')
+    expect(() => manager.startRollback()).toThrow('没有可回滚')
+  })
+})

+ 8 - 0
tests/serviceCli.test.ts

@@ -13,4 +13,12 @@ describe('hermes-yaoyao LaunchAgent plist', () => {
   it('enables Dashboard supervision by default', () => {
     expect(launchAgentPlist()).toContain('<key>HERMES_YAOYAO_SUPERVISE_DASHBOARD</key>')
   })
+
+  it('can point the managed service at the stable current release link', () => {
+    const root = join(homedir(), '.local', 'share', 'hermes-yaoyao', 'current')
+    const plist = launchAgentPlist({ serviceRoot: root })
+
+    expect(plist).toContain(`<string>${join(root, 'dist-server', 'server', 'index.js')}</string>`)
+    expect(plist).toContain(`<key>WorkingDirectory</key><string>${root}</string>`)
+  })
 })

+ 56 - 0
tests/updater.test.ts

@@ -0,0 +1,56 @@
+import { lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { backupPlugin, currentTarget, restorePlugin, switchCurrent, validateManifest } from '../bin/hermes-yaoyao-updater.mjs'
+
+const roots: string[] = []
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+describe('standalone updater primitives', () => {
+  it('atomically switches the stable current symlink', () => {
+    const root = mkdtempSync(join(tmpdir(), 'hermes-updater-'))
+    roots.push(root)
+    const first = join(root, 'releases', '0.2.0-a')
+    const second = join(root, 'releases', '0.3.0-b')
+    switchCurrent(root, first, 'first')
+    expect(currentTarget(root)).toBe(first)
+    switchCurrent(root, second, 'second')
+    expect(currentTarget(root)).toBe(second)
+    expect(lstatSync(join(root, 'current')).isSymbolicLink()).toBe(true)
+  })
+
+  it('rejects a mismatched release manifest before any service transition', () => {
+    expect(() => validateManifest({
+      schemaVersion: 1,
+      releaseVersion: '0.3.0',
+      webVersion: '0.3.1',
+      pluginVersion: '1.8.0',
+      gitTag: 'v0.3.0',
+    })).toThrow('版本组合不一致')
+  })
+
+  it('backs up and restores plugin code without touching durable plugin data', () => {
+    const root = mkdtempSync(join(tmpdir(), 'hermes-updater-plugin-'))
+    roots.push(root)
+    const hermesHome = join(root, 'hermes')
+    const dataHome = join(root, 'web-data')
+    const dashboard = join(hermesHome, 'plugins', 'yaoyao', 'dashboard')
+    const durable = join(hermesHome, 'plugin-data', 'yaoyao')
+    mkdirSync(dashboard, { recursive: true })
+    mkdirSync(durable, { recursive: true })
+    writeFileSync(join(dashboard, 'manifest.json'), JSON.stringify({ version: '1.7.1' }))
+    writeFileSync(join(dashboard, 'plugin.py'), 'old plugin')
+    writeFileSync(join(hermesHome, 'plugins', 'yaoyao', 'plugin.yaml'), 'version: "1.7.1"\n')
+    writeFileSync(join(durable, 'archive.db'), 'durable data')
+
+    const backup = backupPlugin({ hermesHome, dataHome }, '11111111-1111-4111-8111-111111111111')
+    writeFileSync(join(dashboard, 'plugin.py'), 'new plugin')
+    restorePlugin(backup.pluginRoot, backup.backupRoot, 'restore', true)
+
+    expect(readFileSync(join(dashboard, 'plugin.py'), 'utf8')).toBe('old plugin')
+    expect(readFileSync(join(durable, 'archive.db'), 'utf8')).toBe('durable data')
+  })
+})