Przeglądaj źródła

补齐服务端 Hermes Dashboard 系统服务重启功能

samien 1 miesiąc temu
rodzic
commit
976994e1f6

+ 3 - 2
docs/local-vm.md

@@ -42,8 +42,9 @@
 - 每个 Profile 单独显示安装版本、实际加载版本和状态。缺少后台入口时,先安装或更新默认 Profile,再安装命名 Profile。
 - “安装并启用”只启用选中的工具桥及其工具集,保留其他插件、禁用项和模型配置。每次安装保留原插件与配置备份,界面可查看备份位置。
 - 检查会比较插件文件指纹与 Hermes 启动时加载的指纹;同版本修复后未重启也会显示“待重启”,不会仅凭安装文件判断就绪。
-- 安装不会自动重启 Hermes。由 Yaoyao 桌面服务自己启动并持有进程的本机 Dashboard(`127.0.0.1:9119`),可在此页面点击“重启 Hermes Dashboard”,完成后自动检查各 Profile 的实际加载状态。重启影响整个 Dashboard,而非单个 Profile。
-- 正在执行任务、安装工具桥或已有重启操作时不可重启;重启期间暂不接受新任务,定时任务等待下一次检查。外部启动的 Dashboard、Docker 目录映射与远程 Hermes 仍需在所在节点重启,再点击“重新检查”。正在执行任务时暂不可安装。
+- 安装不会自动重启 Hermes。页面中的“重启服务端 Hermes Dashboard”始终通过当前连接的 Yaoyao 服务端执行;页面显示的 `127.0.0.1:9119` 指服务端本机,手机、浏览器或桌面客户端不会在自己的电脑上执行重启。重启影响整个 Dashboard,而非单个 Profile,完成后自动检查各 Profile 的实际加载状态。
+- 支持 Yaoyao 服务端自己持有的 Dashboard 子进程,以及 macOS 服务端的 `com.samien.hermes.dashboard.local` / `ai.hermes.dashboard` LaunchAgent。系统服务须使用 Hermes 启动命令,且其运行 PID 必须与服务端 9119 的唯一监听进程匹配;重启前重新核对,重启后确认新 PID 和 HTTP 健康状态。无需开启桌面模式或 Dashboard 自动拉起。
+- 正在执行任务、安装工具桥或已有重启操作时不可重启;重启期间暂不接受新任务,定时任务等待下一次检查。无法确认归属的外部进程、其他系统的外部服务、Docker 目录映射与远程 Hermes 会保留禁用按钮并显示原因,需在 Hermes 所在节点重启,再点击“重新检查”。正在执行任务时暂不可安装。
 - 本机安装用于当前服务可访问的本机 Hermes。Docker 可显式映射对应 Hermes 数据目录以安装工具桥,详见 [Docker 目录映射](docker-install.md#映射-hermes-目录以安装工具桥);其他远程节点支持状态检查,需在对应节点安装。
 
 管理员 API:`GET /api/app/admin/hermes-bridge`、`POST /api/app/admin/hermes-bridge/install` 与 `POST /api/app/admin/hermes-bridge/restart`。重启接口无需参数,状态中的 `dashboard` 提供托管状态、是否可重启及原因;安装参数仅为 `profile` 和可选的 `enable`。不接受客户端指定重启目标或执行命令。

+ 5 - 5
src/client/components/app/HermesBridgePanel.vue

@@ -76,19 +76,19 @@ onBeforeUnmount(()=>{disposed=true;++generation;clearTimeout(timer)})
           </button>
         </article>
       </div>
-      <div v-if="status.dashboard?.managed || restarting" class="dashboard-action">
-        <p id="hermes-dashboard-restart-hint">{{ restarting ? '正在重启 Hermes Dashboard 并检查工具桥加载状态…' : status.dashboard?.message }}</p>
-        <button type="button" class="restart" aria-describedby="hermes-dashboard-restart-hint" :disabled="checking || !!installing || restarting || !status.dashboard?.canRestart" @click="restart">{{ restarting || status.dashboard?.restarting ? '重启中…' : '重启 Hermes Dashboard' }}</button>
+      <div v-if="status.dashboard || restarting" class="dashboard-action">
+        <div><p id="hermes-dashboard-restart-hint">{{ restarting ? '正在重启服务端 Hermes Dashboard 并检查工具桥加载状态…' : status.dashboard?.message }}</p><small>服务端 Hermes 地址:{{ status.endpoint }}</small></div>
+        <button type="button" class="restart" aria-describedby="hermes-dashboard-restart-hint" :disabled="checking || !!installing || restarting || !status.dashboard?.canRestart" @click="restart">{{ restarting || status.dashboard?.restarting ? '重启中…' : '重启服务端 Hermes Dashboard' }}</button>
       </div>
     </template>
     <div v-if="notice" class="result" role="status"><p>{{ notice }}</p><details v-if="backup"><summary>查看备份位置</summary><code>{{ backup }}</code></details></div>
-    <p class="footnote">安装前会备份原插件与配置。{{ status?.dashboard?.managed ? '安装完成后,可在空闲时点击重启 Hermes Dashboard,完成后自动检查。' : '安装完成后,请在 Hermes 所在节点重启 Dashboard 服务,再点击重新检查。' }}</p>
+    <p class="footnote">安装前会备份原插件与配置。{{ status?.dashboard?.managed ? '安装完成后,可在空闲时点击重启服务端 Hermes Dashboard,完成后自动检查。' : '安装完成后,请在 Hermes 所在节点重启 Dashboard 服务,再点击重新检查。' }}</p>
   </section>
 </template>
 
 <style scoped>
 .hermes-bridge{display:grid;gap:14px;margin-top:22px;padding-top:22px;border-top:1px solid var(--line);color:var(--text-primary);font-size:13px;line-height:1.65}
-.dashboard-action{display:flex;align-items:center;justify-content:space-between;gap:16px}.dashboard-action p{min-width:0}.restart{color:var(--accent);white-space:normal}
+.dashboard-action{display:flex;align-items:center;justify-content:space-between;gap:16px}.dashboard-action>div{min-width:0;overflow-wrap:anywhere}.restart{color:var(--accent);white-space:normal}
 @media(max-width:600px){.dashboard-action{align-items:stretch;flex-direction:column;gap:12px}.restart{align-self:flex-start;max-width:100%}}
 header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}h4{margin:0 0 5px;font-size:15px}p{margin:0;color:var(--text-secondary)}button{flex-shrink:0;min-height:44px;padding:8px 14px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}button:hover:not(:disabled){background:var(--surface-soft)}button:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button:disabled{opacity:.55;cursor:default}.profiles{display:grid;border:1px solid var(--line);border-radius:12px;overflow:hidden}.profile{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px;background:var(--surface-soft)}.profile+.profile{border-top:1px solid var(--line)}.profile-detail{min-width:0;display:grid;gap:5px;overflow-wrap:anywhere}.profile-heading{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.profile-heading strong{font-size:14px}.badge{border:1px solid var(--line);border-radius:6px;padding:1px 7px;font-size:12px;color:var(--text-secondary);background:var(--surface)}.badge.ready{color:var(--success,var(--accent))}.badge.outdated,.badge.restart-required{color:var(--accent)}small,.footnote{font-size:12px;color:var(--text-secondary)}.error{color:var(--danger)}.result{padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft)}.result details{margin-top:8px}.result summary{cursor:pointer}.result code{display:block;margin-top:8px;white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px}.install{color:var(--accent)}
 @media(max-width:600px){header{flex-wrap:wrap}.profile{align-items:stretch;flex-direction:column;gap:12px}.install{align-self:flex-start}header button{margin-left:auto}}

+ 2 - 2
src/server/app.ts

@@ -68,10 +68,10 @@ import {SharedComputers} from './sharedComputers.js'
 import {ComputerControlService} from './computerControls.js'
 import { RunnerHub } from './runnerHub.js'
 import { HermesBridgeManager } from './hermesBridge.js'
-import type { DashboardSupervisor } from './dashboardSupervisor.js'
+import type { DashboardController } from './dashboardController.js'
 
 export interface ApplicationOptions {
-  dashboardSupervisor?: Pick<DashboardSupervisor,'canRestart'|'restart'>
+  dashboardSupervisor?: DashboardController
   hermesBridge?: HermesBridgeManager
   config?: ServerConfig
   fetchImpl?: typeof fetch

+ 126 - 0
src/server/dashboardController.ts

@@ -0,0 +1,126 @@
+import { execFile } from 'node:child_process'
+import { basename } from 'node:path'
+import { promisify } from 'node:util'
+
+export interface DashboardController {
+  readonly canRestart: boolean
+  readonly unavailableReason?: string
+  refresh?(): Promise<void>
+  restart(): Promise<void>
+}
+
+type Run = (command: string, args: string[]) => Promise<string>
+interface Options {
+  platform?: NodeJS.Platform
+  uid?: number
+  run?: Run
+  healthy?: (origin: string) => Promise<boolean>
+  readyTimeoutMs?: number
+  pollIntervalMs?: number
+}
+interface Service { target: string; pid: number }
+interface OwnedDashboard extends DashboardController { suspendChecks?(): () => void }
+
+const execFileAsync = promisify(execFile)
+const labels = ['com.samien.hermes.dashboard.local', 'ai.hermes.dashboard']
+const localOrigin = 'http://127.0.0.1:9119'
+
+async function healthy(origin: string): Promise<boolean> {
+  try {
+    const response = await fetch(`${origin}/api/health`, { signal: AbortSignal.timeout(1000), redirect: 'error' })
+    await response.body?.cancel()
+    return response.ok
+  } catch { return false }
+}
+
+/** Runs only in the Yaoyao server. No browser, desktop IPC or client-supplied target is used. */
+export class ServerDashboardController implements DashboardController {
+  private readonly platform: NodeJS.Platform
+  private readonly uid: number | undefined
+  private readonly run: Run
+  private readonly healthy: NonNullable<Options['healthy']>
+  private readonly timeout: number
+  private readonly interval: number
+  private service: Service | undefined
+  private restarting = false
+  private stopped = false
+
+  constructor(private readonly upstream: URL, private readonly owned?: OwnedDashboard, options: Options = {}) {
+    this.platform = options.platform ?? process.platform
+    this.uid = options.uid ?? process.getuid?.()
+    this.run = options.run ?? (async (command, args) => (await execFileAsync(command, args, { timeout: 3000, maxBuffer: 256 * 1024 })).stdout)
+    this.healthy = options.healthy ?? healthy
+    this.timeout = options.readyTimeoutMs ?? 15000
+    this.interval = options.pollIntervalMs ?? 250
+  }
+
+  get canRestart(): boolean {
+    return !this.stopped && this.upstream.origin === localOrigin && (!!this.owned?.canRestart || !!this.service)
+  }
+
+  get unavailableReason(): string {
+    if (this.upstream.origin !== localOrigin) return '此 Hermes 不在夭夭服务端可管理的本机地址,请在 Hermes 所在节点重启。'
+    if (this.platform !== 'darwin') return '夭夭服务端尚未支持此系统的外部 Dashboard 服务管理,请在 Hermes 所在节点重启。'
+    return '夭夭服务端未识别到与 9119 监听进程匹配的 Hermes Dashboard 系统服务,请在 Hermes 所在节点重启。'
+  }
+
+  async refresh(): Promise<void> {
+    if (this.restarting || this.stopped) return
+    const service = this.owned?.canRestart ? undefined : await this.discover()
+    if (!this.restarting && !this.stopped) this.service = service
+  }
+
+  stop(): void { this.stopped = true; this.service = undefined }
+
+  private async discover(): Promise<Service | undefined> {
+    if (this.stopped || this.upstream.origin !== localOrigin || this.platform !== 'darwin' || this.uid === undefined) return
+    for (const label of labels) {
+      const target = `gui/${this.uid}/${label}`
+      try {
+        const detail = await this.run('/bin/launchctl', ['print', target])
+        const pid = Number(detail.match(/^\s*pid = (\d+)\s*$/m)?.[1])
+        const args = detail.match(/^\s*arguments = \{\n([\s\S]*?)^\s*\}/m)?.[1]?.trim().split('\n').map(line => line.trim()) ?? []
+        const dashboard = args.indexOf('dashboard')
+        // Allow the Hermes executable or its Python entry point, never an arbitrary job with a familiar label.
+        const hermes = dashboard === 1 && basename(args[0]!) === 'hermes'
+          || dashboard === 2 && /^python(?:\d+(?:\.\d+)*)?$/.test(basename(args[0]!)) && basename(args[1]!) === 'hermes'
+        if (!pid || !hermes) continue
+        const listeners = await this.run('/usr/sbin/lsof', ['-nP', '-a', '-iTCP:9119', '-sTCP:LISTEN', '-Fp'])
+        const pids = new Set([...listeners.matchAll(/^p(\d+)$/gm)].map(match => Number(match[1])))
+        if (pids.size === 1 && pids.has(pid)) return { target, pid }
+      } catch { /* Missing job, permissions or an unknown listener: fail closed. */ }
+    }
+  }
+
+  async restart(): Promise<void> {
+    if (this.restarting) throw new Error('服务端 Hermes Dashboard 正在重启。')
+    if (this.stopped || this.upstream.origin !== localOrigin) throw new Error(this.unavailableReason)
+    this.restarting = true
+    this.service = undefined
+    let resumeChecks: (() => void) | undefined
+    try {
+      const owned = !!this.owned?.canRestart
+      let previous: Service | undefined
+      if (owned) await this.owned!.restart()
+      else {
+        resumeChecks = this.owned?.suspendChecks?.()
+        // Refresh ownership at action time, even if the UI previously reported restart availability.
+        previous = await this.discover()
+        if (!previous || this.stopped) throw new Error(this.unavailableReason)
+        await this.run('/bin/launchctl', ['kickstart', '-k', previous.target])
+      }
+      const deadline = Date.now() + this.timeout
+      while (!this.stopped && Date.now() < deadline) {
+        const replacement = owned ? undefined : await this.discover()
+        const replaced = owned ? this.owned?.canRestart
+          : replacement?.target === previous!.target && replacement.pid !== previous!.pid
+        if (replaced && await this.healthy(this.upstream.origin) && !this.stopped) {
+          this.service = replacement
+          return
+        }
+        await new Promise(resolve => setTimeout(resolve, this.interval))
+      }
+      throw new Error('服务端 Hermes Dashboard 重启后未恢复健康,请重新检查。')
+    } finally { resumeChecks?.(); this.restarting = false }
+  }
+}

+ 10 - 2
src/server/dashboardSupervisor.ts

@@ -62,6 +62,7 @@ export class DashboardSupervisor {
   private readonly intervalMs: number
   private timer: NodeJS.Timeout | undefined
   private checking = false
+  private suspendedChecks = 0
   private owned: ChildProcess | undefined
   private stopped = false
   private readonly managed: boolean
@@ -99,6 +100,13 @@ export class DashboardSupervisor {
     this.timer = undefined
   }
 
+  /** Keep legacy availability checks from racing a system service restart. */
+  suspendChecks(): () => void {
+    this.suspendedChecks++
+    let resumed = false
+    return () => { if (!resumed) { resumed = true; this.suspendedChecks-- } }
+  }
+
   async restart(): Promise<void> {
     if (this.restarting) throw new Error('Hermes Dashboard 正在重启,请等待完成。')
     this.restarting = true
@@ -126,7 +134,7 @@ export class DashboardSupervisor {
   }
 
   async checkNow(): Promise<void> {
-    if (this.checking || this.restarting) return
+    if (this.checking || this.restarting || this.suspendedChecks) return
     this.checking = true
     try {
       await this.ensureRunning()
@@ -141,7 +149,7 @@ export class DashboardSupervisor {
     // Installation must not rewrite credentials or restart/rebind an existing
     // upstream. Only a missing managed service is started on loopback.
     const running = await this.probe('127.0.0.1', DASHBOARD_PORT)
-    if (running || this.stopped || this.owned) return
+    if (running || this.stopped || this.owned || this.suspendedChecks) return
     this.log(`Hermes Dashboard is unavailable on 9119; starting it on ${this.dashboardHost}.`)
     if (this.managed) {
       const env: NodeJS.ProcessEnv = { ...process.env, HERMES_PARENT_PID: String(process.pid) }

+ 17 - 9
src/server/hermesBridge.ts

@@ -8,14 +8,14 @@ import {z} from 'zod'
 import {HttpError} from './errors.js'
 import {isLoopbackHost,type ServerConfig} from './config.js'
 import type {UpstreamServiceSession} from './localAuth.js'
-import type {DashboardSupervisor} from './dashboardSupervisor.js'
+import type {DashboardController} from './dashboardController.js'
 import type {HermesBridgeStatus,HermesBridgeProfileStatus,HermesBridgeInstallResult,HermesDashboardRestartResult} from '../shared/hermesBridge.js'
 
 const execFileAsync=promisify(execFile)
 const profileName=z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/)
 const installInput=z.object({profile:profileName,enable:z.boolean().default(false)}).strict()
 interface LocalProfile {profile:string;exists:boolean;valid:boolean;installedVersion?:string;enabled?:boolean;disabled?:boolean;fingerprint?:string;filesCurrent?:boolean;message?:string}
-interface Options {home?:string;python?:string;assetsRoot?:string;isIdle?:()=>boolean;isRestartIdle?:()=>boolean;dashboard?:Pick<DashboardSupervisor,'canRestart'|'restart'>;run?:(python:string,args:string[])=>Promise<string>}
+interface Options {home?:string;python?:string;assetsRoot?:string;isIdle?:()=>boolean;isRestartIdle?:()=>boolean;dashboard?:DashboardController;run?:(python:string,args:string[])=>Promise<string>}
 
 function assetRoot(){
   const base=dirname(fileURLToPath(import.meta.url))
@@ -49,15 +49,18 @@ export class HermesBridgeManager {
   get idleForUpdate(){return !this.installing&&!this.restarting}
   get dashboardRestarting(){return this.restarting}
   assertDashboardAvailable(){if(this.restarting)throw new HttpError(409,'Hermes Dashboard 正在重启,请稍后再提交任务。','hermes_dashboard_restarting')}
+  private get serverDashboard(){return this.local&&!this.mapped&&this.config.upstream.origin==='http://127.0.0.1:9119'}
+  private get dashboardManaged(){return this.serverDashboard&&!!this.options.dashboard?.canRestart}
+  private get restartUnavailable(){return !this.serverDashboard?'此 Hermes 不在夭夭服务端可管理的本机地址,请在 Hermes 所在节点重启。':this.options.dashboard?.unavailableReason??'夭夭服务端未识别到可管理的 Hermes Dashboard 服务,请在 Hermes 所在节点重启。'}
   private dashboardStatus(restartComplete=false):NonNullable<HermesBridgeStatus['dashboard']>{
     const restarting=this.restarting&&!restartComplete
-    const managed=this.local&&!this.mapped&&this.config.upstream.origin==='http://127.0.0.1:9119'&&!!this.options.dashboard?.canRestart
+    const managed=this.dashboardManaged
     const busy=!(this.options.isIdle?.()??true)
     return {managed:managed||restarting,restarting,canRestart:managed&&!restarting&&!this.installing&&!busy,
       message:restarting?'正在重启 Hermes Dashboard 并检查工具桥加载状态…':
-        !managed?'仅支持重启由 Yaoyao 自己启动并管理的本机 Hermes Dashboard。':
+        !managed?this.restartUnavailable:
         this.installing?'工具桥正在安装,请等待完成后重启。':busy?'当前仍有任务运行,请结束任务后重启。':
-        '重启会短暂断开此 Dashboard 下所有 Profile 的连接,完成后自动检查工具桥。'}
+        '重启在夭夭服务端执行,会短暂断开此 Dashboard 下所有 Profile 的连接,完成后自动检查工具桥。'}
   }
   private get available(){return this.local&&this.bundled&&existsSync(this.python)&&existsSync(join(this.home,'config.yaml'))}
   private async disk():Promise<LocalProfile[]>{
@@ -69,6 +72,7 @@ export class HermesBridgeManager {
   }
   async status():Promise<HermesBridgeStatus>{return this.snapshot()}
   private async snapshot(restartComplete=false):Promise<HermesBridgeStatus>{
+    if(this.serverDashboard&&(!this.restarting||restartComplete))await this.options.dashboard?.refresh?.()
     const [disk,remote]=await Promise.allSettled([this.disk(),this.session.request('/api/profiles',{cache:'reload'})])
     const localProfiles=disk.status==='fulfilled'?disk.value:[]
     const connected=remote.status==='fulfilled'&&remote.value.status===200
@@ -138,20 +142,24 @@ export class HermesBridgeManager {
       +'请在空闲时重启 Hermes Dashboard 服务,然后重新检查。'
     return {profile:parsed.data.profile,backup:result.backup,message,status:await this.status()}
   }
-  async restartDashboard():Promise<HermesDashboardRestartResult>{
+  async restartDashboard(value:unknown={}):Promise<HermesDashboardRestartResult>{
+    if(!z.object({}).strict().safeParse(value).success)throw new HttpError(400,'重启目标由夭夭服务端确定,不接受客户端指定目标或命令。','invalid_dashboard_restart_request')
     this.assertDashboardAvailable()
-    if(!this.dashboardStatus().managed)throw new HttpError(409,'只能重启由 Yaoyao 自己启动并管理的本机 Hermes Dashboard。','hermes_dashboard_restart_unavailable')
+    if(!this.serverDashboard||!this.options.dashboard)throw new HttpError(409,this.restartUnavailable,'hermes_dashboard_restart_unavailable')
     if(this.installing)throw new HttpError(409,'工具桥正在安装,请等待完成。','hermes_bridge_install_busy')
     if(!(this.options.isIdle?.()??true)||!(this.options.isRestartIdle?.()??true))throw new HttpError(409,'当前仍有任务运行,请结束任务后重启。','hermes_bridge_tasks_running')
     this.restarting=true
     try{
+      await this.options.dashboard.refresh?.()
+      if(!this.dashboardManaged)throw new HttpError(409,this.restartUnavailable,'hermes_dashboard_restart_unavailable')
       await this.options.dashboard!.restart()
       this.session.invalidateAuthentication?.()
       const status=await this.snapshot(true)
-      if(!status.dashboard?.managed)throw new Error('Dashboard ownership lost during verification')
+      if(!this.dashboardManaged)throw new Error('Dashboard ownership lost during verification')
       const ready=status.profiles.every(profile=>profile.state==='ready')
       return {message:ready?'Hermes Dashboard 已重启,工具桥已就绪。':'Hermes Dashboard 已重启。部分 Profile 的工具桥尚未就绪,请查看各 Profile 状态。',status}
-    }catch{
+    }catch(error){
+      if(error instanceof HttpError)throw error
       throw new HttpError(409,'Hermes Dashboard 重启或状态检查失败,请重新检查连接和工具桥状态。','hermes_dashboard_restart_failed')
     }finally{this.restarting=false}
   }

+ 4 - 1
src/server/index.ts

@@ -12,6 +12,7 @@ import { readBuildIdentity } from './buildIdentity.js'
 import { migrateDataHome } from '../../bin/lib/data-migration.mjs'
 import { legacyDataHome, resolveDataHome } from '../../bin/lib/data-home.mjs'
 import { spawn } from 'node:child_process'
+import { ServerDashboardController } from './dashboardController.js'
 
 migrateDataHome(resolveDataHome(process.env.YAOYAO_HOME || process.env.HERMES_YAOYAO_HOME, { preserveActiveUpdate: true }))
 const config = loadServerConfig()
@@ -27,7 +28,8 @@ const dashboardSupervisor = config.superviseDashboard
   && config.upstream.protocol === 'http:' && config.upstream.port === '9119'
   ? new DashboardSupervisor({ managed: process.env.HERMES_YAOYAO_DESKTOP === '1' })
   : undefined
-const runtime = createApplication({config, dashboardSupervisor})
+const dashboardController = new ServerDashboardController(config.upstream, dashboardSupervisor)
+const runtime = createApplication({config, dashboardSupervisor: dashboardController})
 const nodeRuntime = createNodeServer(runtime)
 let closeFrontend = async (): Promise<void> => undefined
 // Native update admission runs before routes (including streamed requests).
@@ -101,6 +103,7 @@ async function shutdown(): Promise<void> {
   if (closing) return
   closing = true
   instance.beginShutdown()
+  dashboardController.stop()
   try {
     dashboardSupervisor?.stop()
     await closeFrontend()

+ 1 - 1
src/server/routes.ts

@@ -1826,7 +1826,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   })
   router.post('/api/app/admin/hermes-bridge/restart', async ctx => {
     dependencies.auth.requireAdmin(ctx)
-    json(ctx,200,await dependencies.hermesBridge.restartDashboard())
+    json(ctx,200,await dependencies.hermesBridge.restartDashboard(body(ctx)))
   })
   router.get('/api/app/admin/model-services', async (ctx) => {
     await proxyAdminFeature(ctx, dependencies, '/api/providers/custom-endpoints', {

+ 7 - 4
tests/client/hermesBridgePanel.test.ts

@@ -69,16 +69,19 @@ it('shows one instance-wide restart action with progress and the verified result
   wrapper.unmount()
 })
 
-it('hides restart for unmanaged instances and disables it while tasks or installs are active',async()=>{
+it('keeps the server restart action visible with its unavailable reason and target',async()=>{
   const state=managedSnapshot();state.dashboard!.canRestart=false;state.dashboard!.message='当前仍有任务运行,请结束任务后重启。'
   vi.mocked(getHermesBridgeStatus).mockResolvedValue(state)
   const wrapper=mount(HermesBridgePanel);await flushPromises()
   expect(wrapper.get('.restart').attributes('disabled')).toBeDefined()
   expect(wrapper.text()).toContain('当前仍有任务运行')
   await wrapper.get('.restart').trigger('click');expect(restartHermesDashboard).not.toHaveBeenCalled()
-  state.dashboard!.managed=false
+  state.dashboard!.managed=false;state.dashboard!.message='服务端未识别到可管理的 Hermes Dashboard 系统服务。'
   await wrapper.findAll('button').find(b=>b.text()==='重新检查')!.trigger('click');await flushPromises()
-  expect(wrapper.find('.restart').exists()).toBe(false)
+  expect(wrapper.get('.restart').attributes('disabled')).toBeDefined()
+  expect(wrapper.get('.restart').text()).toBe('重启服务端 Hermes Dashboard')
+  expect(wrapper.text()).toContain('服务端未识别到')
+  expect(wrapper.text()).toContain('http://127.0.0.1:9119')
   expect(wrapper.text()).toContain('Hermes 所在节点')
   wrapper.unmount()
 })
@@ -91,7 +94,7 @@ it('refreshes capabilities after failure and preserves the restart error',async(
   vi.mocked(getHermesBridgeStatus).mockResolvedValue(state)
   await wrapper.get('.restart').trigger('click');await flushPromises()
   expect(wrapper.get('[role="alert"]').text()).toContain('重启失败')
-  expect(wrapper.find('.restart').exists()).toBe(false)
+  expect(wrapper.get('.restart').attributes('disabled')).toBeDefined()
   expect(wrapper.text()).not.toContain('工具桥已就绪')
   wrapper.unmount()
 })

+ 6 - 4
tests/e2e/hermes-bridge.spec.ts

@@ -7,7 +7,7 @@ test('checks and installs the selected Profile bridge and displays its actual ac
   let phase=0,managed=true,finishInstall!:(value:void)=>void,finishRestart!:(value:void)=>void
   const installations:unknown[]=[],restarts:unknown[]=[]
   const status=()=>({endpoint:'http://127.0.0.1:9119',local:true,bundledVersion:'1.2.0',checkedAt:Date.now(),
-    dashboard:{managed,canRestart:managed,restarting:false,message:'重启会短暂断开此 Dashboard 下所有 Profile 的连接,完成后自动检查工具桥。'},profiles:[
+    dashboard:{managed,canRestart:managed,restarting:false,message:managed?'重启在夭夭服务端执行,会短暂断开此 Dashboard 下所有 Profile 的连接,完成后自动检查工具桥。':'夭夭服务端未识别到可管理的 Hermes Dashboard 服务,请在 Hermes 所在节点重启。'},profiles:[
     {profile:'default',state:'ready',message:'Hermes 已加载当前工具桥,可以使用',installedVersion:'1.2.0',loadedVersion:'1.2.0',canInstall:true},
     {profile:'server',state:phase===0?'missing':phase===1?'restart-required':'ready',message:phase===0?'尚未安装工具桥插件':phase===1?'插件文件已就位;请在空闲时重启 Hermes 后重新检查':'Hermes 已加载当前工具桥,可以使用',installedVersion:phase?'1.2.0':undefined,loadedVersion:phase===2?'1.2.0':undefined,canInstall:true},
   ]})
@@ -43,10 +43,11 @@ test('checks and installs the selected Profile bridge and displays its actual ac
   expect(installations).toEqual([{profile:'server',enable:true}])
   await page.screenshot({path:evidence+'/desktop-installed.png'})
   await page.setViewportSize({width:375,height:812})
-  await panel.getByRole('button',{name:'重启 Hermes Dashboard',exact:true}).scrollIntoViewIfNeeded()
+  await panel.getByRole('button',{name:'重启服务端 Hermes Dashboard',exact:true}).scrollIntoViewIfNeeded()
+  await expect(panel.getByText('服务端 Hermes 地址:http://127.0.0.1:9119',{exact:true})).toBeVisible()
   expect(await panel.evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true)
   await page.screenshot({path:evidence+'/mobile-restart.png'})
-  await panel.getByRole('button',{name:'重启 Hermes Dashboard',exact:true}).click()
+  await panel.getByRole('button',{name:'重启服务端 Hermes Dashboard',exact:true}).click()
   await expect(panel.getByRole('button',{name:'重启中…',exact:true})).toBeDisabled()
   await expect(panel.getByRole('button',{name:'重新检查',exact:true})).toBeDisabled()
   await expect(panel).toHaveAttribute('aria-busy','true')
@@ -70,5 +71,6 @@ test('checks and installs the selected Profile bridge and displays its actual ac
   await page.screenshot({path:evidence+'/dark-ready.png'})
   managed=false
   await panel.getByRole('button',{name:'重新检查',exact:true}).click()
-  await expect(panel.getByRole('button',{name:'重启 Hermes Dashboard',exact:true})).toHaveCount(0)
+  await expect(panel.getByRole('button',{name:'重启服务端 Hermes Dashboard',exact:true})).toBeDisabled()
+  await expect(panel.getByText(/夭夭服务端未识别到/)).toBeVisible()
 })

+ 136 - 0
tests/server/dashboardController.test.ts

@@ -0,0 +1,136 @@
+// @vitest-environment node
+import { expect, it, vi } from 'vitest'
+import { ServerDashboardController } from '../../src/server/dashboardController'
+
+const origin = new URL('http://127.0.0.1:9119')
+const target = 'gui/501/com.samien.hermes.dashboard.local'
+
+function fixture(options: { upstream?: URL; platform?: NodeJS.Platform; label?: string; command?: string; owned?: { canRestart: boolean; restart(): Promise<void>; suspendChecks?(): () => void } } = {}) {
+  let pid = 1043, listener = 1043, replacement = 2043, available = true, health = true
+  const service = options.label ? `gui/501/${options.label}` : target
+  const run = vi.fn(async (command: string, args: string[]) => {
+    if (command === '/bin/launchctl' && args[0] === 'print') {
+      if (!available || args[1] !== service) throw new Error('job not found')
+      return `${service} = {\n\targuments = {\n\t\t${options.command ?? '/server/.hermes/venv/bin/python\n\t\t/server/.hermes/hermes-agent/hermes\n\t\tdashboard'}\n\t\t--host\n\t\t127.0.0.1\n\t}\n\tpid = ${pid}\n}`
+    }
+    if (command === '/usr/sbin/lsof') return `p${listener}\n`
+    if (command === '/bin/launchctl' && args[0] === 'kickstart') { pid = replacement; listener = replacement; return '' }
+    throw new Error('unexpected command')
+  })
+  const healthy = vi.fn(async () => health)
+  const controller = new ServerDashboardController(options.upstream ?? origin, options.owned, {
+    platform: options.platform ?? 'darwin', uid: 501, run, healthy, readyTimeoutMs: 15, pollIntervalMs: 1,
+  })
+  const mutations = () => run.mock.calls.filter(([, args]) => args[0] === 'kickstart')
+  return { controller, run, healthy, mutations, set: (state: { available?: boolean; listener?: number; replacement?: number; healthy?: boolean }) => {
+    if (state.available !== undefined) available = state.available
+    if (state.listener !== undefined) listener = state.listener
+    if (state.replacement !== undefined) replacement = state.replacement
+    if (state.healthy !== undefined) health = state.healthy
+  } }
+}
+
+it('discovers and restarts the server LaunchAgent, then verifies a new listener and HTTP health', async () => {
+  const f = fixture()
+  expect(f.controller.canRestart).toBe(false)
+  await f.controller.refresh()
+  expect(f.controller.canRestart).toBe(true)
+  expect(f.mutations()).toEqual([])
+  await f.controller.restart()
+  expect(f.mutations()).toEqual([['/bin/launchctl', ['kickstart', '-k', target]]])
+  expect(f.healthy).toHaveBeenCalledWith('http://127.0.0.1:9119')
+  expect(f.controller.canRestart).toBe(true)
+})
+
+it('supports the standard Hermes LaunchAgent only when it owns the configured listener', async () => {
+  const f = fixture({ label: 'ai.hermes.dashboard', command: '/server/.local/bin/hermes\n\t\tdashboard' })
+  await f.controller.refresh(); await f.controller.restart()
+  expect(f.mutations()[0]?.[1]).toEqual(['kickstart', '-k', 'gui/501/ai.hermes.dashboard'])
+})
+
+it.each([
+  new URL('https://hermes.example'), new URL('http://192.168.1.5:9119'), new URL('http://127.0.0.1:9120'),
+])('never runs local commands for an upstream on another server or port: %s', async upstream => {
+  const owned = { canRestart: true, restart: vi.fn(async () => {}) }
+  const f = fixture({ upstream, owned })
+  await f.controller.refresh()
+  expect(f.controller.canRestart).toBe(false)
+  await expect(f.controller.restart()).rejects.toThrow('所在节点')
+  expect(f.run).not.toHaveBeenCalled(); expect(owned.restart).not.toHaveBeenCalled()
+})
+
+it('does not run launchctl on an unsupported server OS', async () => {
+  const f = fixture({ platform: 'linux' })
+  await f.controller.refresh()
+  await expect(f.controller.restart()).rejects.toThrow('尚未支持')
+  expect(f.run).not.toHaveBeenCalled()
+})
+
+it.each(['missing', 'other-listener', 'other-command', 'multiple-listeners'])('refuses an unverified service: %s', async mode => {
+  const f = fixture(mode === 'other-command' ? { command: '/usr/bin/node\n\t\tdashboard' } : {})
+  if (mode === 'missing') f.set({ available: false })
+  if (mode === 'other-listener') f.set({ listener: 999 })
+  if (mode === 'multiple-listeners') {
+    const original = f.run.getMockImplementation()!
+    f.run.mockImplementation(async (command, args) => command === '/usr/sbin/lsof' ? 'p1043\np999\n' : original(command, args))
+  }
+  await f.controller.refresh()
+  expect(f.controller.canRestart).toBe(false)
+  await expect(f.controller.restart()).rejects.toThrow('未识别到')
+  expect(f.mutations()).toEqual([])
+})
+
+it('revalidates ownership immediately before restarting, regardless of an earlier available status', async () => {
+  const f = fixture()
+  await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
+  f.set({ listener: 999 })
+  await expect(f.controller.restart()).rejects.toThrow('未识别到')
+  expect(f.mutations()).toEqual([])
+  expect(f.controller.canRestart).toBe(false)
+})
+
+it.each(['same-pid', 'unhealthy'])('does not report success until replacement is verified: %s', async mode => {
+  const f = fixture()
+  f.set(mode === 'same-pid' ? { replacement: 1043 } : { healthy: false })
+  await expect(f.controller.restart()).rejects.toThrow('未恢复健康')
+  expect(f.controller.canRestart).toBe(false)
+  f.set({ replacement: 3043, healthy: true })
+  await f.controller.restart()
+  expect(f.controller.canRestart).toBe(true)
+})
+
+it('preserves owned-child restart support and never touches a system service in that case', async () => {
+  const owned = { canRestart: true, restart: vi.fn(async () => {}) }
+  const f = fixture({ owned })
+  await f.controller.refresh(); await f.controller.restart()
+  expect(owned.restart).toHaveBeenCalledTimes(1)
+  expect(f.run).not.toHaveBeenCalled()
+  expect(f.healthy).toHaveBeenCalledWith(origin.origin)
+})
+
+it.each([true, false])('suspends automatic starts during a system restart and releases them on success=%s', async success => {
+  const resume = vi.fn()
+  const owned = { canRestart: false, restart: vi.fn(async () => {}), suspendChecks: vi.fn(() => resume) }
+  const f = fixture({ owned })
+  f.healthy.mockImplementation(async () => {
+    expect(owned.suspendChecks).toHaveBeenCalledTimes(1)
+    expect(resume).not.toHaveBeenCalled()
+    return success
+  })
+  if (success) await f.controller.restart()
+  else await expect(f.controller.restart()).rejects.toThrow('未恢复健康')
+  expect(owned.restart).not.toHaveBeenCalled()
+  expect(resume).toHaveBeenCalledTimes(1)
+})
+
+it('serializes restarts and stops verification when the server shuts down', async () => {
+  let release!: () => void
+  const owned = { canRestart: true, restart: vi.fn(() => new Promise<void>(done => { release = done })) }
+  const f = fixture({ owned })
+  const pending = f.controller.restart()
+  await expect(f.controller.restart()).rejects.toThrow('正在重启')
+  f.controller.stop(); release()
+  await expect(pending).rejects.toThrow('未恢复健康')
+  expect(f.controller.canRestart).toBe(false)
+  expect(f.run).not.toHaveBeenCalled()
+})

+ 19 - 0
tests/server/dashboardSupervisor.test.ts

@@ -46,6 +46,25 @@ describe('DashboardSupervisor', () => {
     await Promise.all([f.instance.checkNow(), f.instance.checkNow()])
     expect(f.launches).toHaveLength(1)
   })
+  it('does not launch a competing process while a system service is being restarted', async () => {
+    const f = supervisor()
+    const resume = f.instance.suspendChecks()
+    await f.instance.checkNow()
+    expect(f.launches).toEqual([])
+    resume(); resume()
+    await f.instance.checkNow()
+    expect(f.launches).toHaveLength(1)
+  })
+  it('also suspends a check that was already waiting for its listener probe', async () => {
+    let probeDone!: (running: boolean) => void
+    const launch = vi.fn()
+    const instance = new DashboardSupervisor({ launch, probe: () => new Promise(done => { probeDone = done }) })
+    const pending = instance.checkNow()
+    const resume = instance.suspendChecks()
+    probeDone(false); await pending
+    expect(launch).not.toHaveBeenCalled()
+    resume()
+  })
 })
 
 function managedSupervisor(initiallyRunning=false, readyTimeoutMs=1000) {

+ 25 - 1
tests/server/hermesBridge.test.ts

@@ -6,6 +6,7 @@ import {join,resolve} from 'node:path'
 import request from 'supertest'
 import {HermesBridgeManager} from '../../src/server/hermesBridge'
 import type {ServerConfig} from '../../src/server/config'
+import type {DashboardController} from '../../src/server/dashboardController'
 import {createAuthenticatedApplication,createUserAuthenticatedApplication} from './authenticatedApplication'
 
 let home:string,config:ServerConfig
@@ -14,7 +15,7 @@ beforeEach(()=>{
   config={host:'127.0.0.1',port:15300,upstream:new URL('http://127.0.0.1:9119'),home,allowedHosts:new Set(),mediaRoot:home,attachmentsRoot:home,imagesRoot:home,mediaOwner:'fixture',allowInsecureLan:false,insecureLan:false,production:false}
 })
 afterEach(()=>{rmSync(home,{recursive:true,force:true});vi.restoreAllMocks();vi.unstubAllEnvs()})
-function fixture(dashboard?:{canRestart:boolean;restart:()=>Promise<void>}){
+function fixture(dashboard?:DashboardController){
   let disk:any[]=[{profile:'default',exists:true,valid:true,enabled:true,installedVersion:'1.1.0',fingerprint:'old',filesCurrent:false},{profile:'writer',exists:true,valid:true,enabled:false,disabled:true,installedVersion:'1.2.0',fingerprint:'new',filesCurrent:true}]
   let live:any={ready:true,native_tools:true,in_process:true,computer_runtime_version:2,plugin_version:'1.1.0',plugin_fingerprint:'old'}
   let idle=true,gate:Promise<void>|undefined
@@ -56,6 +57,28 @@ it('restarts the managed Dashboard and verifies every Profile using fresh loaded
   expect(result.message).toContain('工具桥已就绪')
 })
 
+it('discovers a server-managed Dashboard without desktop ownership and locks task admission while discovering',async()=>{
+  let release!:()=>void
+  const dashboard={canRestart:false,refresh:vi.fn(async()=>{await new Promise<void>(done=>{release=done});dashboard.canRestart=true}),restart:vi.fn(async()=>{})}
+  const f=fixture(dashboard)
+  const pending=f.manager.restartDashboard()
+  expect(()=>f.manager.assertDashboardAvailable()).toThrow('正在重启')
+  await expect(f.manager.restartDashboard()).rejects.toMatchObject({code:'hermes_dashboard_restarting'})
+  await expect(f.manager.install({profile:'default'})).rejects.toMatchObject({code:'hermes_dashboard_restarting'})
+  dashboard.refresh.mockImplementation(async()=>{})
+  release();await pending
+  expect(dashboard.restart).toHaveBeenCalledTimes(1)
+  expect(f.manager.dashboardRestarting).toBe(false)
+})
+
+it('rejects client-supplied hosts, processes and commands before accessing a server service',async()=>{
+  const dashboard={canRestart:true,refresh:vi.fn(async()=>{}),restart:vi.fn(async()=>{})},f=fixture(dashboard)
+  for(const body of [{host:'client'},{pid:123},{command:'killall python'},{target:'gui/501/other'},{profile:'writer'}]){
+    await expect(f.manager.restartDashboard(body)).rejects.toMatchObject({status:400,code:'invalid_dashboard_restart_request'})
+  }
+  expect(dashboard.refresh).not.toHaveBeenCalled();expect(dashboard.restart).not.toHaveBeenCalled()
+})
+
 it('keeps an unverified bridge pending after restart and releases the lock after failure',async()=>{
   const dashboard={canRestart:true,restart:vi.fn(async()=>{})},f=fixture(dashboard)
   await f.manager.install({profile:'default'})
@@ -123,6 +146,7 @@ it('requires admin and CSRF for restart, and blocks API and websocket task entry
     const api=request.agent(admin.app.callback()),host='127.0.0.1:15300',path='/api/app/admin/hermes-bridge/restart'
     await api.post(path).set('Host',host).send({}).expect(403)
     const csrf=(await api.get('/api/app/bootstrap?csrfOnly=1').set('Host',host).expect(200)).body.csrfToken
+    await api.post(path).set('Host',host).set('Origin','http://'+host).set('X-CSRF-Token',csrf).send({target:'client'}).expect(400)
     const post=(url:string)=>api.post(url).set('Host',host).set('Origin','http://'+host).set('X-CSRF-Token',csrf).send({})
     const pending=post(path).expect(200).then(response=>response)
     await vi.waitFor(()=>expect(dashboard.restart).toHaveBeenCalledTimes(1))