Forráskód Böngészése

解耦 Web 独立升级与 9119 插件管理

LSamien 1 hónapja
szülő
commit
52d4ae1ccf

+ 8 - 5
README.md

@@ -185,14 +185,14 @@ Content-Type: application/json
 插件目录。登录 Web 后还会比较发布清单与 9119 的实际插件版本,落后时自动通过
 同一接口更新。
 
-### Web 与插件配套升级
+### Web 独立升级
 
 macOS 受管服务可在左下角 Agent 菜单中打开“系统更新”。Git 发布标签负责 Web
 版本,`release.json` 同时声明兼容的插件版本;插件本身始终通过 9119 独立同步。
 
 升级接口固定读取 `HERMES_YAOYAO_RELEASE_SOURCE`,只接受发布源中最新的
 `vX.Y.Z` 标签,并在下载后核对标签解析出的 Git 提交和发布清单。浏览器不能传入
-仓库地址或任意提交。写接口仍受 Hermes 登录、Origin 和 CSRF 保护;默认还要求
+仓库地址或任意提交。所有更新接口都要求 8800 本地管理员登录,写接口仍受 Origin 和 CSRF 保护;默认还要求
 请求来自本机,可通过 `HERMES_YAOYAO_ALLOW_REMOTE_UPDATE=1` 明确允许远程管理。
 
 首次成功升级会把运行文件迁入:
@@ -203,9 +203,12 @@ macOS 受管服务可在左下角 Agent 菜单中打开“系统更新”。Git
 └── current -> releases/<版本>-<提交>
 ```
 
-插件会先由已登录的 Web 会话通过 9119 更新并核对版本。独立 updater 只下载、
-构建和切换 Web 的 `current`,不会停止 9119、调用 `hermes config` 或读写
-`HERMES_HOME`。Web 验证失败时只回滚 Web;插件继续由 9119 保持最新版本。
+检查、执行、查询任务和回滚 Web 都不连接 9119,也不会先检测或更新插件。
+9119 离线、响应超时、认证失败或插件版本落后均不阻止 Web 升级;Git 发布源仍需可访问。
+登录初始化最多等待上游 3 秒,超时后仍可进入设置管理和更新 Web。
+独立 updater 只下载、构建和切换 Web 的 `current`,不会停止 9119、调用 `hermes config` 或读写
+`HERMES_HOME`。升级和回滚只用 `/healthz` 验证 Web 自身;`/readyz` 保留上游连通性诊断,
+不参与升级成败判定。Web 验证失败时只回滚 Web;插件由 9119 恢复后独立同步。
 用户数据始终留在 `~/.hermes-yaoyao` 和
 `~/.hermes/plugin-data/yaoyao`,不进入版本目录。
 

+ 7 - 6
bin/hermes-yaoyao-updater.mjs

@@ -197,20 +197,21 @@ export function restorePreviousService({
   }
 }
 
-async function responseJSON(url) {
-  const response = await fetch(url, { signal: AbortSignal.timeout(3_000), cache: 'no-store' })
+async function responseJSON(url, fetchImpl) {
+  const response = await fetchImpl(url, { signal: AbortSignal.timeout(3_000), cache: 'no-store' })
   if (!response.ok) fail(`${url} 返回 HTTP ${response.status}`)
   return response.json()
 }
 
-async function verifyRuntime(timeoutMs = 45_000) {
+export async function verifyRuntime(timeoutMs = 45_000, fetchImpl = fetch) {
   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')
-      if (health.ok === true && ready.ok === true) return
+      // /readyz deliberately includes 9119 reachability. Only the Web's own
+      // health decides update/rollback success, including older releases.
+      const health = await responseJSON('http://127.0.0.1:8800/healthz', fetchImpl)
+      if (health.ok === true) return
       lastError = 'Web 服务尚未就绪'
     } catch (error) {
       lastError = error instanceof Error ? error.message : String(error)

+ 11 - 4
docs/agent-install.md

@@ -242,11 +242,13 @@ node bin/hermes-yaoyao.mjs service install
 Git、发布目录、命令输出或日志。配置后检查系统管理中的 APNs/FCM 独立状态;一方
 未配置或失败不能使另一方停止。
 
-## 7. 通过受管服务进行配套升级
+## 7. 通过受管服务独立升级 Web
 
 首次按第 6 步安装 Web `0.2.28` 后,可在左下角 Agent 菜单打开“系统更新”。
 检查更新只读取固定的 `HERMES_YAOYAO_RELEASE_SOURCE`,并锁定最新 `vX.Y.Z`
-标签解析到的 Git 提交;执行升级前仍会检查插件持久目录是否安全。
+标签解析到的 Git 提交。更新状态、检查、执行、任务查询和回滚仅要求 8800 本地管理员登录;
+写请求仍验证 Origin、CSRF 和本机访问策略,不要求 9119 可用或通过上游认证。
+不会检查插件持久目录或强制先升级插件。9119 离线时,登录初始化最多等待 3 秒后仍可进入更新页。
 
 升级任务由 8800 外部的独立 updater 执行。运行文件会安装到
 `~/.local/share/hermes-yaoyao/releases/`,LaunchAgent 改为指向稳定的
@@ -257,13 +259,18 @@ Git、发布目录、命令输出或日志。配置后检查系统管理中的 A
 时,才在重新安装 LaunchAgent 前设置 `HERMES_YAOYAO_ALLOW_REMOTE_UPDATE=1`。
 Docker 和其他非 macOS 部署必须替换镜像,不能调用服务内升级接口。
 
-升级成功后至少验证:
+Web 升级成功必须通过自身健康检查,并确认发布目录:
 
 ```bash
 curl --noproxy '*' --fail --silent http://127.0.0.1:8800/healthz
+readlink ~/.local/share/hermes-yaoyao/current
+```
+
+下列是独立的上游诊断。9119 离线时可失败,不应因此判定 Web 升级失败或回滚:
+
+```bash
 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. 失败处理

+ 4 - 4
src/client/components/app/SystemOverviewPanel.vue

@@ -41,8 +41,7 @@ const deviceStatus = computed(() => devices.value ? `${devices.value.devices.len
 const voiceStatus = computed(() => voice.value ? `已配置 ${voice.value.voices.length} 个音色` : '尚未读取语音状态')
 const updateStatus = computed(() => {
   if (!update.value) return '尚未读取版本状态'
-  const plugin = update.value.installedPluginVersion || update.value.current.pluginVersion || '未安装'
-  return `Web ${update.value.current.webVersion} · 插件 ${plugin}`
+  return `Web ${update.value.current.webVersion} · 可独立升级与回滚`
 })
 
 async function refresh() {
@@ -53,7 +52,8 @@ async function refresh() {
     getPushSystemStatus(),
     pairedDevices(),
     getDuplexVoiceSettings(),
-    systemUpdateStatus(),
+    // Publish local update status immediately, without waiting for 9119 panels.
+    systemUpdateStatus().then(value => { update.value = value; return value }),
   ])
   if (results[0]?.status === 'fulfilled') users.value = results[0].value
   if (results[1]?.status === 'fulfilled') push.value = results[1].value
@@ -104,7 +104,7 @@ watch(() => props.active, active => { if (active) void refresh() }, { immediate:
       </button>
       <button type="button" @click="emit('navigate', 'system-update')">
         <AppIcon name="refresh" :size="22" />
-        <span><strong>更新与回滚</strong><small>{{ updateStatus }} <b v-if="update && !update.updateAvailable && update.versionsMatch">已是最新版本</b></small></span>
+        <span><strong>更新与回滚</strong><small>{{ updateStatus }} <b v-if="update?.latest && !update.updateAvailable">已是最新版本</b></small></span>
         <AppIcon class="chevron" name="chevron-left" :size="18" />
       </button>
     </div>

+ 6 - 6
src/client/components/app/SystemUpdateDialog.vue

@@ -22,7 +22,7 @@ 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 currentPlugin = computed(() => status.value?.installedPluginVersion || '未检测(不影响 Web 升级)')
 const canApply = computed(() => Boolean(status.value?.supported && status.value.updateAvailable && status.value.latest && !active.value && !busy.value))
 
 function stopPolling() { pollToken += 1 }
@@ -61,7 +61,7 @@ async function poll(next: UpdateJob) {
 
 async function applyUpdate() {
   const target = status.value?.latest
-  if (!target || !window.confirm(`升级 Web 到 ${target.webVersion}?插件 ${target.pluginVersion} 将通过 9119 更新。`)) return
+  if (!target || !window.confirm(`仅升级 Web 到 ${target.webVersion}?无需连接 9119,不会更新插件。`)) return
   busy.value = true
   error.value = ''
   try {
@@ -74,7 +74,7 @@ async function applyUpdate() {
 }
 
 async function rollback() {
-  if (!window.confirm('回滚到上一次 Web 版本?插件继续由 9119 保持最新版本。')) return
+  if (!window.confirm('仅回滚到上一次 Web 版本?无需连接 9119,插件保持不变。')) return
   busy.value = true
   error.value = ''
   try {
@@ -111,7 +111,7 @@ onBeforeUnmount(stopPolling)
             <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 插件由 9119 自动更新到最新兼容版本。</p>
+          <p class="system-update-intro">Web 服务独立升级与回滚,9119 离线或认证失败也不影响。这里只检查 Web 发布源,不检测或更新插件;插件由 9119 独立管理。</p>
           <p v-if="error" class="system-update-error"><AppIcon name="alert" :size="15" />{{ error }}</p>
 
           <section v-if="status" class="version-grid">
@@ -121,7 +121,7 @@ onBeforeUnmount(stopPolling)
             <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" />当前插件版本落后,登录后会自动通过 9119 更新。</p>
+          <p v-if="status?.installedPluginVersion && !status.versionsMatch" class="version-warning"><AppIcon name="alert" :size="14" />插件版本与 Web 清单不一致,可在 9119 恢复后单独更新;不影响 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>
 
@@ -134,7 +134,7 @@ onBeforeUnmount(stopPolling)
             <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>
+            <button class="solid-button" type="button" :disabled="!canApply" @click="applyUpdate"><AppIcon name="download" :size="16" />{{ active ? '升级中…' : status?.updateAvailable ? '升级 Web' : '已是最新版本' }}</button>
           </footer>
         </section>
       </div>

+ 6 - 6
src/client/components/app/SystemUpdatePanel.vue

@@ -27,7 +27,7 @@ let lifecycleToken = 0
 const terminal = computed(() => job.value && ['succeeded', 'failed', 'rolled_back'].includes(job.value.state))
 const jobRunning = computed(() => Boolean(job.value && !terminal.value))
 const locked = computed(() => operationStarting.value || (jobRunning.value && !trackingTimedOut.value))
-const currentPlugin = computed(() => status.value?.installedPluginVersion || '未安装')
+const currentPlugin = computed(() => status.value?.installedPluginVersion || '未检测(不影响 Web 升级)')
 const canApply = computed(() => Boolean(status.value?.supported && status.value.updateAvailable && status.value.latest && !jobRunning.value && !busy.value))
 
 function stopPolling() { pollToken += 1 }
@@ -82,7 +82,7 @@ async function loadAndResume(token: number) {
 
 async function applyUpdate() {
   const target = status.value?.latest
-  if (!target || !window.confirm(`升级 Web 到 ${target.webVersion}?插件 ${target.pluginVersion} 将通过 9119 更新。`)) return
+  if (!target || !window.confirm(`仅升级 Web 到 ${target.webVersion}?无需连接 9119,不会更新插件。`)) return
   const token = lifecycleToken
   operationStarting.value = true
   busy.value = true
@@ -103,7 +103,7 @@ async function applyUpdate() {
 }
 
 async function rollback() {
-  if (!window.confirm('回滚到上一次 Web 版本?插件继续由 9119 保持最新版本。')) return
+  if (!window.confirm('仅回滚到上一次 Web 版本?无需连接 9119,插件保持不变。')) return
   const token = lifecycleToken
   operationStarting.value = true
   busy.value = true
@@ -145,7 +145,7 @@ onBeforeUnmount(() => { lifecycleToken += 1; operationStarting.value = false; st
 
 <template>
   <section class="system-update-panel" aria-label="更新与回滚">
-    <p class="system-update-intro">Web 服务独立升级;Hermes Dashboard 插件由 9119 自动更新到最新兼容版本。</p>
+    <p class="system-update-intro">Web 服务独立升级与回滚,9119 离线或认证失败也不影响。这里只检查 Web 发布源,不检测或更新插件;插件由 9119 独立管理。</p>
     <p v-if="error" class="system-update-error" role="alert"><AppIcon name="alert" :size="16" />{{ error }}</p>
 
     <section v-if="status" class="version-grid" aria-label="版本信息">
@@ -155,7 +155,7 @@ onBeforeUnmount(() => { lifecycleToken += 1; operationStarting.value = false; st
       <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="15" />当前插件版本落后,登录后会自动通过 9119 更新。</p>
+    <p v-if="status?.installedPluginVersion && !status.versionsMatch" class="version-warning"><AppIcon name="alert" :size="15" />插件版本与 Web 清单不一致,可在 9119 恢复后单独更新;不影响 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>
 
@@ -168,7 +168,7 @@ onBeforeUnmount(() => { lifecycleToken += 1; operationStarting.value = false; st
       <button v-if="job?.state === 'succeeded' || job?.state === 'rolled_back'" class="quiet-button" type="button" @click="reloadPage"><AppIcon name="refresh" :size="16" />刷新页面</button>
       <button v-else-if="status?.canRollback && !jobRunning" class="quiet-button danger" type="button" :disabled="busy" @click="rollback">回滚上一版本</button>
       <button class="quiet-button" type="button" :disabled="checking || busy || operationStarting || jobRunning" @click="refresh(true)"><AppIcon name="refresh" :size="16" />{{ checking ? '检查中…' : '检查更新' }}</button>
-      <button class="solid-button" type="button" :disabled="!canApply" @click="applyUpdate"><AppIcon name="download" :size="16" />{{ jobRunning ? '升级中…' : status?.updateAvailable ? '升级配套版本' : '已是最新版本' }}</button>
+      <button class="solid-button" type="button" :disabled="!canApply" @click="applyUpdate"><AppIcon name="download" :size="16" />{{ jobRunning ? '升级中…' : status?.updateAvailable ? '升级 Web' : '已是最新版本' }}</button>
     </footer>
   </section>
 </template>

+ 60 - 73
src/server/routes.ts

@@ -951,20 +951,37 @@ async function bootstrap(
   let upstreamReady = false
   let upstreamError: string | undefined
   if (!user.mustChangePassword) {
+    let timeout: ReturnType<typeof setTimeout> | undefined
     try {
-      const statusResponse = await dependencies.upstreamSession.request('/api/status')
-      const rawStatus = requireSuccess(statusResponse)
-      status = publicStatus(rawStatus)
-      const profilesResponse = await dependencies.upstreamSession.request('/api/profiles')
-      const pluginProfilesResponse = await dependencies.upstreamSession.request('/api/plugins/yaoyao/profiles')
-      profiles = profilesWithHermesBotNames(
-        normalizedProfiles(requireSuccess(profilesResponse)),
-        pluginProfilesResponse.status >= 200 && pluginProfilesResponse.status < 300
-          ? parseJson(pluginProfilesResponse) : undefined,
-      )
+      // Local login/settings must remain usable even when 9119 never responds.
+      // Publish only a complete result; a late probe cannot mutate this response.
+      const upstream = await Promise.race([
+        (async () => {
+          const statusResponse = await dependencies.upstreamSession.request('/api/status')
+          const rawStatus = requireSuccess(statusResponse)
+          const profilesResponse = await dependencies.upstreamSession.request('/api/profiles')
+          const pluginProfilesResponse = await dependencies.upstreamSession.request('/api/plugins/yaoyao/profiles')
+          return {
+            status: publicStatus(rawStatus),
+            profiles: profilesWithHermesBotNames(
+              normalizedProfiles(requireSuccess(profilesResponse)),
+              pluginProfilesResponse.status >= 200 && pluginProfilesResponse.status < 300
+                ? parseJson(pluginProfilesResponse) : undefined,
+            ),
+          }
+        })(),
+        new Promise<never>((_resolve, reject) => {
+          timeout = setTimeout(() => reject(new Error('9119 连接超时;仍可管理 8800 和升级 Web')), 3_000)
+          timeout.unref()
+        }),
+      ])
+      status = upstream.status
+      profiles = upstream.profiles
       upstreamReady = true
     } catch (error) {
       upstreamError = error instanceof Error ? error.message : '9119 不可用'
+    } finally {
+      if (timeout) clearTimeout(timeout)
     }
   }
   json(ctx, 200, {
@@ -1924,15 +1941,10 @@ 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, systemUpdateStatusForRequest(
-        ctx,
-        dependencies,
-        dependencies.updates.status(installedPluginVersion),
-      ))
-    })
+    dependencies.auth.requireAdmin(ctx)
+    // No upstream calls, including best-effort plugin probes: a stalled 9119
+    // must not delay or prevent access to the independent Web updater.
+    json(ctx, 200, systemUpdateStatusForRequest(ctx, dependencies, dependencies.updates.status()))
   })
   router.get('/api/app/system/push-status', (ctx) => {
     dependencies.auth.requireAdmin(ctx)
@@ -1985,66 +1997,41 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     json(ctx, 200, pushSystemStatus(dependencies))
   })
   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, systemUpdateStatusForRequest(
-          ctx,
-          dependencies,
-          await dependencies.updates.check(installedPluginVersion),
-        ))
-      } catch (error) {
-        throw updateFailure(error)
-      }
-    })
+    dependencies.auth.requireAdmin(ctx)
+    try {
+      json(ctx, 200, systemUpdateStatusForRequest(ctx, dependencies, await dependencies.updates.check()))
+    } 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 {
-        const release = await dependencies.updates.check(installedPluginVersion)
-        if (!release.latest || release.latest.releaseVersion !== targetVersion) {
-          throw new Error('目标版本不是当前发布源的最新版本')
-        }
-        await reconcileYaoyaoPlugin(
-          dependencies,
-          jar,
-          ctx.req.socket.remoteAddress,
-          release.latest.pluginVersion,
-        )
-        json(ctx, 202, await dependencies.updates.startUpdate(targetVersion, installedPluginVersion))
-      } catch (error) {
-        throw updateFailure(error)
-      }
-    })
+    dependencies.auth.requireAdmin(ctx)
+    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')
+    }
+    try {
+      json(ctx, 202, await dependencies.updates.startUpdate(targetVersion))
+    } 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)
-    })
+    dependencies.auth.requireAdmin(ctx)
+    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)
-      }
-    })
+    dependencies.auth.requireAdmin(ctx)
+    requireLocalSystemUpdate(ctx, dependencies)
+    try {
+      json(ctx, 202, dependencies.updates.startRollback())
+    } catch (error) {
+      throw updateFailure(error)
+    }
   })
   router.post('/api/app/plugins/yaoyao/reconcile', async (ctx) => {
     await withJar(ctx, async (jar) => {

+ 25 - 0
tests/client/systemOverviewPanel.test.ts

@@ -0,0 +1,25 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import SystemOverviewPanel from '@/components/app/SystemOverviewPanel.vue'
+
+vi.mock('@/api/admin', () => ({ listUsers: vi.fn(async () => []) }))
+vi.mock('@/api/push', () => ({ getPushSystemStatus: vi.fn(async () => ({ configured: false })) }))
+vi.mock('@/api/pairing', () => ({ pairedDevices: vi.fn(() => new Promise(() => {})) }))
+vi.mock('@/api/agentManagement', () => ({ getDuplexVoiceSettings: vi.fn(() => new Promise(() => {})) }))
+vi.mock('@/api/systemUpdate', () => ({ systemUpdateStatus: vi.fn(async () => ({
+  current: { webVersion: '0.2.29', pluginVersion: '1.7.3' }, versionsMatch: false, updateAvailable: false,
+})) }))
+
+describe('SystemOverviewPanel offline updates', () => {
+  it('shows Web status and allows update navigation while upstream panels are stalled', async () => {
+    const wrapper = mount(SystemOverviewPanel, { props: { upstreamReady: false }, global: { stubs: { AppIcon: true } } })
+    await flushPromises()
+    expect(wrapper.text()).toContain('Web 0.2.29 · 可独立升级与回滚')
+    expect(wrapper.text()).not.toContain('已是最新版本')
+    expect(wrapper.text()).not.toContain('插件 1.7.3')
+    const update = wrapper.findAll('button').find(button => button.text().includes('更新与回滚'))!
+    await update.trigger('click')
+    expect(wrapper.emitted('navigate')).toEqual([['system-update']])
+    wrapper.unmount()
+  })
+})

+ 22 - 1
tests/client/systemUpdatePanel.test.ts

@@ -60,6 +60,27 @@ afterEach(() => {
 })
 
 describe('SystemUpdatePanel', () => {
+  it('allows Web updates with unknown plugin state and explains the offline boundary', async () => {
+    const offlineStatus = { ...readyStatus, installedPluginVersion: undefined, versionsMatch: false, updateAvailable: true,
+      latest: { ...manifest, releaseVersion: '0.3.0', webVersion: '0.3.0', gitTag: 'v0.3.0' } }
+    api.systemUpdateStatus.mockResolvedValue(offlineStatus)
+    api.checkSystemUpdate.mockResolvedValue(offlineStatus)
+    api.applySystemUpdate.mockResolvedValue(doneJob)
+    const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true)
+    const wrapper = mount(SystemUpdatePanel, { global: { stubs: { AppIcon: true } } })
+    await flushPromises()
+    expect(wrapper.text()).toContain('9119 离线或认证失败也不影响')
+    expect(wrapper.text()).toContain('未检测(不影响 Web 升级)')
+    expect(wrapper.find('.version-warning').exists()).toBe(false)
+    const apply = wrapper.find<HTMLButtonElement>('.solid-button')
+    expect(apply.element.disabled).toBe(false)
+    await apply.trigger('click'); await flushPromises()
+    expect(confirm).toHaveBeenCalledWith('仅升级 Web 到 0.3.0?无需连接 9119,不会更新插件。')
+    expect(api.applySystemUpdate).toHaveBeenCalledWith('0.3.0')
+    expect(wrapper.text()).toContain('更新完成')
+    wrapper.unmount()
+  })
+
   it('resumes polling an existing non-terminal job and releases the settings lock', async () => {
     api.systemUpdateStatus
       .mockResolvedValueOnce({ ...readyStatus, job: activeJob })
@@ -96,7 +117,7 @@ describe('SystemUpdatePanel', () => {
     })
     await flushPromises()
 
-    const apply = wrapper.findAll<HTMLButtonElement>('button').find(button => button.text().includes('升级配套版本'))!
+    const apply = wrapper.findAll<HTMLButtonElement>('button').find(button => button.text().includes('升级 Web'))!
     await apply.trigger('click')
     await wrapper.vm.$nextTick()
     expect(wrapper.emitted('lock-change')?.at(-1)).toEqual([true])

+ 78 - 0
tests/e2e/offlineUpdate.spec.ts

@@ -0,0 +1,78 @@
+import { expect, test } from '@playwright/test'
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { once } from 'node:events'
+import type { AddressInfo } from 'node:net'
+import serve from 'koa-static'
+import send from 'koa-send'
+
+test('opens settings and schedules an independent Web update while all 9119 requests fail', async ({ page }, testInfo) => {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-offline-ui-'))
+  const { createApplication, createNodeServer } = await import(pathToFileURL(join(process.cwd(), 'dist-server/server/app.js')).href)
+  const { SystemUpdateManager } = await import(pathToFileURL(join(process.cwd(), 'dist-server/server/updateManager.js')).href)
+  const current = JSON.parse(readFileSync('release.json', 'utf8'))
+  const [major, minor, patch] = current.webVersion.split('.').map(Number)
+  const version = `${major}.${minor}.${patch + 1}`
+  const latest = { ...current, releaseVersion: version, webVersion: version, gitTag: `v${version}` }
+  const config = {
+    host: '127.0.0.1', port: 0, upstream: new URL('http://127.0.0.1:1'),
+    allowedHosts: new Set(['127.0.0.1']), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+    mediaOwner: 'test', allowInsecureLan: false, insecureLan: false, production: true,
+  }
+  const launched: string[] = []
+  const updates = new SystemUpdateManager(config, {
+    projectRoot: process.cwd(), platform: 'darwin',
+    inspectRemote: async () => ({ manifest: latest, commit: 'b'.repeat(40) }),
+    // Exercise scheduling and polling only; never install or stop a real service.
+    launchUpdater: (path: string) => {
+      const job = JSON.parse(readFileSync(path, 'utf8'))
+      launched.push(job.target.releaseVersion)
+      writeFileSync(path, JSON.stringify({ ...job, state: 'succeeded', message: '离线 Web 升级任务验收完成' }))
+      rmSync(join(home, 'updates', 'active.lock'))
+    },
+  })
+  const runtime = createApplication({ config, updates })
+  runtime.app.use(serve(join(process.cwd(), 'dist')))
+  runtime.app.use(async (ctx: Parameters<ReturnType<typeof serve>>[0]) => {
+    if (ctx.method === 'GET' && ctx.accepts('html')) await send(ctx, 'index.html', { root: join(process.cwd(), 'dist') })
+  })
+  const node = createNodeServer(runtime)
+  node.server.listen(0, '127.0.0.1'); await once(node.server, 'listening')
+  const origin = `http://127.0.0.1:${(node.server.address() as AddressInfo).port}`
+  try {
+    const bootstrap = await (await page.request.get(origin + '/api/app/bootstrap')).json()
+    const login = await page.request.post(origin + '/api/app/login', {
+      headers: { Origin: origin, 'X-CSRF-Token': bootstrap.csrfToken }, data: { username: 'admin', password: 'admin' },
+    })
+    const signedIn = await login.json()
+    expect(login.ok(), `${login.status()} ${signedIn.error || ''}`).toBe(true)
+    const changed = await page.request.put(origin + '/api/app/account/credentials', {
+      headers: { Origin: origin, 'X-CSRF-Token': signedIn.csrfToken },
+      data: { currentPassword: 'admin', newPassword: 'offline-e2e-password' },
+    })
+    expect(changed.ok()).toBe(true)
+    expect((await page.request.get(origin + '/readyz')).status()).toBe(503)
+    await page.goto(origin + '/chat')
+    await page.getByRole('button', { name: '打开设置中心' }).click()
+    const dialog = page.getByRole('dialog', { name: '设置中心' })
+    await dialog.getByRole('button', { name: '系统概览', exact: true }).click()
+    await expect(dialog.getByText(`Web ${current.webVersion} · 可独立升级与回滚`)).toBeVisible()
+    await dialog.getByRole('button', { name: '更新与回滚', exact: true }).click()
+    await expect(dialog.getByText('未检测(不影响 Web 升级)')).toBeVisible()
+    await expect(dialog.getByText(version, { exact: true })).toBeVisible()
+    await expect(dialog.getByRole('alert')).toHaveCount(0)
+    await expect(page.locator('.settings-center-layer')).toHaveCSS('opacity', '1')
+    await page.screenshot({ path: testInfo.outputPath('offline-web-update.png') })
+    page.once('dialog', confirmation => confirmation.accept())
+    await dialog.getByRole('button', { name: '升级 Web', exact: true }).click()
+    await expect(dialog.getByText('离线 Web 升级任务验收完成')).toBeVisible()
+    expect(launched).toEqual([version])
+  } finally {
+    await page.goto('about:blank')
+    node.server.closeAllConnections()
+    await node.close()
+    rmSync(home, { recursive: true, force: true })
+  }
+})

+ 139 - 0
tests/server/offlineSystemUpdate.test.ts

@@ -0,0 +1,139 @@
+// @vitest-environment node
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import request from 'supertest'
+import { afterEach, describe, expect, it, vi } 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[] = []
+const host = '127.0.0.1:8800'
+const origin = `http://${host}`
+const base = '/api/app/system/update'
+const latest = { schemaVersion: 1 as const, releaseVersion: '0.3.0', webVersion: '0.3.0', pluginVersion: '9.0.0', gitTag: 'v0.3.0' }
+
+afterEach(() => {
+  for (const runtime of runtimes.splice(0)) runtime.close()
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function fixture() {
+  const root = mkdtempSync(join(tmpdir(), 'yaoyao-offline-update-')); roots.push(root)
+  const projectRoot = join(root, 'project'); mkdirSync(projectRoot)
+  writeFileSync(join(projectRoot, 'release.json'), JSON.stringify({ ...latest, releaseVersion: '0.2.0', webVersion: '0.2.0', 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: root,
+    attachmentsRoot: root, imagesRoot: root, mediaOwner: 'tester',
+    allowInsecureLan: false, insecureLan: false, production: false,
+    releaseRoot: join(root, 'installed'), releaseSource: 'https://example.test/repo.git',
+  }
+  const fetchImpl = vi.fn<typeof fetch>(async () => { throw new Error('9119 offline') })
+  const inspectRemote = vi.fn(async () => ({ manifest: latest, commit: 'b'.repeat(40) }))
+  const launchUpdater = vi.fn(() => undefined)
+  const updates = new SystemUpdateManager(config, { projectRoot, inspectRemote, launchUpdater, platform: 'darwin' })
+  const runtime = createApplication({ config, fetchImpl, updates }); runtimes.push(runtime)
+  return { runtime, fetchImpl, inspectRemote, launchUpdater, config }
+}
+
+async function login(runtime: ApplicationRuntime, username = 'admin', password = 'admin') {
+  const agent = request.agent(runtime.app.callback())
+  const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
+  const signedIn = await agent.post('/api/app/login').set('Host', host).set('Origin', origin)
+    .set('X-CSRF-Token', boot.body.csrfToken).send({ username, password }).expect(200)
+  const changed = await agent.put('/api/app/account/credentials').set('Host', host).set('Origin', origin)
+    .set('X-CSRF-Token', signedIn.body.csrfToken)
+    .send({ currentPassword: password, newPassword: 'offline-test-password', username }).expect(200)
+  return { agent, csrf: changed.body.csrfToken as string, user: changed.body.user }
+}
+
+describe('independent Web updates', () => {
+  it.each(['offline', 'unauthorized', 'server-error', 'stalled'] as const)('keeps every update route independent of a %s 9119', async failure => {
+    const f = fixture()
+    const { agent, csrf } = await login(f.runtime)
+    if (failure === 'unauthorized') f.fetchImpl.mockResolvedValue(Response.json({}, { status: 401 }))
+    if (failure === 'server-error') f.fetchImpl.mockResolvedValue(Response.json({}, { status: 503 }))
+    if (failure === 'stalled') f.fetchImpl.mockImplementation(() => new Promise<Response>(() => {}))
+    f.fetchImpl.mockClear()
+    const mutation = (path: string) => agent.post(base + path).set('Host', host).set('Origin', origin).set('X-CSRF-Token', csrf)
+    const status = await agent.get(base + '/status').set('Host', host).expect(200)
+    expect(status.body.current.webVersion).toBe('0.2.0')
+    expect(status.body.installedPluginVersion).toBeUndefined()
+    const checked = await mutation('/check').send({}).expect(200)
+    expect(checked.body).toMatchObject({ updateAvailable: true, latest, supported: true })
+    await mutation('/apply').send({ targetVersion: 'arbitrary-ref' }).expect(400)
+    await mutation('/apply').send({ targetVersion: '0.2.1' }).expect(409)
+    expect(f.launchUpdater).not.toHaveBeenCalled()
+    const queued = await mutation('/apply').send({ targetVersion: '0.3.0' }).expect(202)
+    expect(queued.body).toMatchObject({ operation: 'update', target: latest, state: 'queued' })
+    expect(queued.body.plan).toBeUndefined()
+    const jobPath = join(f.config.home, 'updates', `${queued.body.id}.json`)
+    const stored = JSON.parse(readFileSync(jobPath, 'utf8'))
+    expect(stored.plan.targetCommit).toBe('b'.repeat(40))
+    await agent.get(`${base}/jobs/${queued.body.id}`).set('Host', host).expect(200)
+    await mutation('/apply').send({ targetVersion: '0.3.0' }).expect(409)
+    expect(f.launchUpdater).toHaveBeenCalledTimes(1)
+    writeFileSync(jobPath, JSON.stringify({ ...stored, state: 'succeeded' }))
+    rmSync(join(f.config.home, 'updates', 'active.lock'))
+    writeFileSync(join(f.config.home, 'updates', 'last-success.json'), '{}')
+    const rollback = await mutation('/rollback').send({}).expect(202)
+    expect(rollback.body).toMatchObject({ operation: 'rollback', state: 'queued' })
+    await agent.get(`${base}/jobs/${rollback.body.id}`).set('Host', host).expect(200)
+    expect(f.fetchImpl).not.toHaveBeenCalled()
+  })
+
+  it('still requires local login, admin privilege, Origin and CSRF when 9119 is down', async () => {
+    const f = fixture()
+    const anonymous = request.agent(f.runtime.app.callback())
+    const boot = await anonymous.get('/api/app/bootstrap').set('Host', host).expect(200)
+    const getPaths = ['/status', '/jobs/11111111-1111-4111-8111-111111111111']
+    for (const path of getPaths) await anonymous.get(base + path).set('Host', host).expect(401)
+    for (const path of ['/check', '/apply', '/rollback']) {
+      await anonymous.post(base + path).set('Host', host).set('Origin', origin).set('X-CSRF-Token', boot.body.csrfToken)
+        .send({ targetVersion: '0.3.0' }).expect(401)
+    }
+    const admin = await login(f.runtime)
+    f.runtime.auth.create(admin.user, 'member', 'temporary-password')
+    const member = await login(f.runtime, 'member', 'temporary-password')
+    for (const path of getPaths) await member.agent.get(base + path).set('Host', host).expect(403)
+    for (const path of ['/check', '/apply', '/rollback']) {
+      await member.agent.post(base + path).set('Host', host).set('Origin', origin).set('X-CSRF-Token', member.csrf)
+        .send({ targetVersion: '0.3.0' }).expect(403)
+      await admin.agent.post(base + path).set('Host', host).set('Origin', origin).send({}).expect(403)
+      await admin.agent.post(base + path).set('Host', host).set('Origin', 'https://wrong.example')
+        .set('X-CSRF-Token', admin.csrf).send({}).expect(403)
+    }
+    expect(f.fetchImpl).not.toHaveBeenCalled()
+    expect(f.launchUpdater).not.toHaveBeenCalled()
+  })
+
+  it('preserves local login during a stalled bootstrap and still exposes update status', async () => {
+    const f = fixture()
+    const { agent } = await login(f.runtime)
+    let finish!: (response: Response) => void
+    f.fetchImpl.mockImplementation(() => new Promise<Response>(resolve => { finish = resolve }))
+    const started = Date.now()
+    const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
+    expect(Date.now() - started).toBeLessThan(8_000)
+    expect(boot.body).toMatchObject({ authenticated: true, upstreamReady: false, user: { role: 'admin' } })
+    expect(boot.body.upstreamError).toContain('仍可管理 8800 和升级 Web')
+    await agent.get(base + '/status').set('Host', host).expect(200)
+    expect(f.fetchImpl).toHaveBeenCalledTimes(1)
+    finish(Response.json({}, { status: 503 }))
+  }, 10_000)
+
+  it('reports real release-source failures without launching or contacting 9119', async () => {
+    const f = fixture()
+    const { agent, csrf } = await login(f.runtime)
+    f.inspectRemote.mockRejectedValue(new Error('Git source unreachable'))
+    const failed = await agent.post(base + '/check').set('Host', host).set('Origin', origin)
+      .set('X-CSRF-Token', csrf).send({}).expect(502)
+    expect(failed.body).toMatchObject({ code: 'system_update_source_failed' })
+    expect(failed.body.error).toContain('Git source unreachable')
+    expect(f.fetchImpl).not.toHaveBeenCalled()
+    expect(f.launchUpdater).not.toHaveBeenCalled()
+  })
+})

+ 4 - 2
tests/server/systemUpdateRoutes.test.ts

@@ -36,7 +36,7 @@ describe('system update routes', () => {
     expect(systemUpdateRequestAllowed('192.168.1.8', true)).toBe(true)
   })
 
-  it('checks and queues a fixed Web/plugin release through authenticated CSRF routes', async () => {
+  it('checks and queues a fixed Web release without reconciling the plugin', async () => {
     const root = mkdtempSync(join(tmpdir(), 'hermes-system-update-routes-'))
     roots.push(root)
     const projectRoot = join(root, 'project')
@@ -90,7 +90,8 @@ describe('system update routes', () => {
 
     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 })
+    expect(status.body).toMatchObject({ updateAvailable: false })
+    expect(status.body.installedPluginVersion).toBeUndefined()
 
     const check = await mutation('/api/app/system/update/check').send({}).expect(200)
     expect(check.body).toMatchObject({ latest, updateAvailable: true })
@@ -98,6 +99,7 @@ describe('system update routes', () => {
     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)
+    expect(pluginVersion).toBe('1.7.1')
 
     const jobResponse = await request(runtime.app.callback())
       .get(`/api/app/system/update/jobs/${queued.body.id}`)

+ 27 - 0
tests/updater.test.ts

@@ -10,6 +10,7 @@ import {
   serviceInstallInvocation,
   switchCurrent,
   validateManifest,
+  verifyRuntime,
 } from '../bin/hermes-yaoyao-updater.mjs'
 
 const roots: string[] = []
@@ -18,6 +19,32 @@ afterEach(() => {
 })
 
 describe('standalone updater primitives', () => {
+  it('accepts a healthy Web without probing 9119 or upstream-dependent readiness', async () => {
+    const fetchImpl = vi.fn(async (url: string) => {
+      if (url !== 'http://127.0.0.1:8800/healthz') throw new Error('9119 offline')
+      return Response.json({ ok: true })
+    })
+    await verifyRuntime(1_000, fetchImpl)
+    expect(fetchImpl).toHaveBeenCalledTimes(1)
+    expect(fetchImpl.mock.calls[0]?.[0]).toBe('http://127.0.0.1:8800/healthz')
+  })
+
+  it('still fails an unhealthy Web and retries transient startup failures', async () => {
+    vi.useFakeTimers()
+    try {
+      const unhealthy = vi.fn(async () => Response.json({ ok: false }))
+      const check = expect(verifyRuntime(1_000, unhealthy)).rejects.toThrow('升级后健康检查失败')
+      await vi.advanceTimersByTimeAsync(1_000)
+      await check
+      const recovering = vi.fn().mockRejectedValueOnce(new Error('starting'))
+        .mockResolvedValueOnce(Response.json({ ok: true }))
+      const recovered = verifyRuntime(1_000, recovering)
+      await vi.advanceTimersByTimeAsync(500)
+      await recovered
+      expect(recovering).toHaveBeenCalledTimes(2)
+    } finally { vi.useRealTimers() }
+  })
+
   it('atomically switches the stable current symlink', () => {
     const root = mkdtempSync(join(tmpdir(), 'hermes-updater-'))
     roots.push(root)