Explorar el Código

支持通过 Dashboard 安全安装夭夭插件

LSamien hace 2 meses
padre
commit
851582df4f

+ 1 - 0
.dockerignore

@@ -23,6 +23,7 @@ docker.env
 hermes-plugins/*
 !hermes-plugins/yaoyao
 hermes-plugins/yaoyao/*
+!hermes-plugins/yaoyao/plugin.yaml
 !hermes-plugins/yaoyao/dashboard
 !hermes-plugins/yaoyao/dashboard/**
 docs

+ 1 - 1
Dockerfile

@@ -35,7 +35,7 @@ COPY --chown=node:node --from=builder /app/dist-server ./dist-server
 # Keep the Dashboard plugin in the offline image as an installation payload.
 # Hermes itself owns plugin discovery, configuration, and restart, so this is
 # deliberately not copied into the Web container's runtime data directory.
-COPY --chown=node:node hermes-plugins/yaoyao/dashboard /opt/hermes-yaoyao-plugin/dashboard
+COPY --chown=node:node hermes-plugins/yaoyao /opt/hermes-yaoyao-plugin
 
 USER node
 

+ 30 - 0
README.md

@@ -5,6 +5,10 @@
 仓库同时归档了夭夭的 Hermes Dashboard 插件,位于
 [`hermes-plugins/yaoyao/dashboard`](hermes-plugins/yaoyao/dashboard)。Web 工作台和该插件是两个独立部署单元:前者运行在 `8800`,后者由已运行的 Hermes Dashboard 在 `9119` 加载。
 
+插件根目录同时提供 Hermes 标准 `plugin.yaml`,可通过 Dashboard 的 Git
+插件安装接口安装。默认源为
+`https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao`。
+
 当前发布版本:**Git `v0.1.1` / 夭夭 Web `0.1.1` / Hermes Dashboard 插件 `1.7.1`**。
 
 需要由自动化 Agent 部署或升级时,请直接使用 [Agent 安装手册](docs/agent-install.md)。其中包含固定版本校验、备份、同步、单一 Dashboard 重载与验证步骤。
@@ -77,6 +81,32 @@ tail -n 100 ~/.hermes/logs/gui.log
 
 插件的归档数据与运行状态仍位于 Hermes 数据目录中,不在此仓库的 `hermes-plugins/` 中;升级插件前的备份也不应提交到 Git。
 
+### 通过 8800 安全编排 9119 安装
+
+登录夭夭 Web 后,可以调用受登录、Origin 和 CSRF 保护的接口:
+
+```http
+POST /api/app/plugins/yaoyao/install
+Content-Type: application/json
+
+{"force":false}
+```
+
+全新安装使用 `force:false`;已安装版本升级必须明确使用 `force:true`。8800
+只会把服务端配置的固定 Git 源交给 9119,不接受浏览器传入任意仓库地址。
+可通过 `HERMES_YAOYAO_PLUGIN_SOURCE` 覆盖默认源,且配置中禁止嵌入用户名或
+密码。
+
+插件数据会从旧的 `<profile-home>/plugins/yaoyao/data` 一次性迁移到
+`<profile-home>/plugin-data/yaoyao`。如果旧、新目录同时包含数据,安装接口会
+返回 `409 yaoyao_storage_conflict`,不会覆盖任一目录。旧版插件没有迁移检查
+接口时,也会返回 `409 yaoyao_storage_migration_required`;此时先按上面的
+`dashboard/` 局部同步方式安装一次当前兼容版本并重启,之后才能使用一键升级。
+
+本机受管部署会在安装后由 8800 重启 9119;未启用 Dashboard 监督或使用远程
+9119 时,响应中的 `restartRequired` 为 `true`,需要由目标环境的服务管理器
+完成重启。
+
 ## 开发
 
 ```bash

+ 20 - 0
docs/agent-install.md

@@ -123,6 +123,26 @@ test "$(node -e \"console.log(require(process.argv[1]).version)\" "$PLUGIN_DIR/m
 
 不要提交、复制或恢复 `data/`、`__pycache__/`、`.pytest_cache/` 等运行时内容。归档数据库和本机状态不属于插件发布物。
 
+当前兼容版本首次启动时,会把每个 Profile 的旧目录
+`<profile-home>/plugins/yaoyao/data` 原子迁移到
+`<profile-home>/plugin-data/yaoyao`。后者独立于插件安装树,后续通过 Hermes
+插件接口替换 `plugins/yaoyao` 时不会丢失运行数据。若两个目录同时包含数据,
+插件不会自动合并;应人工核对后保留唯一权威目录。
+
+完成这次兼容版本启动后,后续可在已登录的 8800 会话中调用:
+
+```http
+POST /api/app/plugins/yaoyao/install
+Content-Type: application/json
+
+{"force":true}
+```
+
+默认安装源是
+`https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao`,可由服务端
+环境变量 `HERMES_YAOYAO_PLUGIN_SOURCE` 覆盖。接口不接受请求体覆盖仓库地址,
+并会在调用 9119 的通用安装接口前检查持久数据迁移状态。
+
 ## 3. 由 8800 服务担任唯一 Dashboard 监督者
 
 插件文件只有在 Dashboard 重载后才会生效。安装本项目的受管服务后,8800 服务是 Dashboard 的唯一持续监督者;不要保留其他 Dashboard LaunchAgent 或手动常驻进程。

+ 62 - 0
hermes-plugins/yaoyao/dashboard/data_paths.py

@@ -0,0 +1,62 @@
+"""Durable storage paths and one-time migration for the YaoYao plugin.
+
+Plugin installers replace ``<profile_home>/plugins/yaoyao`` atomically. Runtime
+state therefore lives under ``<profile_home>/plugin-data/yaoyao`` so installs,
+updates, and removals cannot delete user data.
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+
+DURABLE_DATA_SUBPATH = Path("plugin-data") / "yaoyao"
+LEGACY_DATA_SUBPATH = Path("plugins") / "yaoyao" / "data"
+
+
+@dataclass(frozen=True)
+class DataPathStatus:
+    data_root: Path
+    ready: bool
+    migrated: bool
+    legacy_present: bool
+    conflict: bool
+
+
+def _has_entries(path: Path) -> bool:
+    try:
+        return next(path.iterdir(), None) is not None
+    except FileNotFoundError:
+        return False
+
+
+def ensure_durable_data_root(profile_home: Path) -> DataPathStatus:
+    """Move the legacy data directory once, without merging ambiguous trees.
+
+    When both locations contain data, continue serving the legacy tree and
+    report a conflict. The install coordinator refuses a full plugin replace
+    until an operator resolves that conflict, preventing silent data loss.
+    """
+
+    home = Path(profile_home).expanduser().resolve()
+    durable = home / DURABLE_DATA_SUBPATH
+    legacy = home / LEGACY_DATA_SUBPATH
+
+    legacy_present = legacy.exists()
+    if not legacy_present:
+        return DataPathStatus(durable, True, False, False, False)
+
+    if legacy.is_symlink() or durable.is_symlink():
+        return DataPathStatus(legacy, False, False, True, True)
+
+    if durable.exists():
+        if _has_entries(legacy):
+            return DataPathStatus(legacy, False, False, True, True)
+        return DataPathStatus(durable, True, False, True, False)
+
+    durable.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+    os.replace(legacy, durable)
+    return DataPathStatus(durable, True, True, False, False)
+

+ 2 - 1
hermes-plugins/yaoyao/dashboard/group_store.py

@@ -18,6 +18,7 @@ from dataclasses import dataclass
 from pathlib import Path
 from typing import Callable, Iterator, Mapping
 
+from .data_paths import ensure_durable_data_root
 from .group_protocol import (
     ALL_MENTION_ALIASES,
     CONTEXT_CHARACTER_BUDGET,
@@ -329,7 +330,7 @@ class GroupStore:
     ) -> "GroupStore":
         hermes_home = Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes")
         return cls(
-            hermes_home / "plugins" / "yaoyao" / "data" / "group-chat.db",
+            ensure_durable_data_root(hermes_home).data_root / "group-chat.db",
             agent_name_resolver=agent_name_resolver,
         )
 

+ 41 - 0
hermes-plugins/yaoyao/dashboard/plugin_api.py

@@ -42,6 +42,7 @@ if _THIS_DIR not in sys.path:
 import store  # noqa: E402
 import poller  # noqa: E402
 import voice_store  # noqa: E402
+import data_paths  # noqa: E402
 
 
 def _profile_home(profile: Optional[str]) -> Path:
@@ -168,6 +169,46 @@ except Exception:
     log.exception("yaoyao: poller failed to start (routes still serve existing data)")
 
 
+@router.get("/maintenance/storage")
+def maintenance_storage():
+    """Report whether every profile's runtime data is safe across updates."""
+
+    profiles: list[dict[str, object]] = []
+    ready = True
+    try:
+        discovered = poller._discover_profiles()
+    except Exception as error:
+        log.warning("yaoyao: unable to discover profile storage: %s", error)
+        discovered = [("default", _profile_home("default"))]
+    seen: set[str] = set()
+    for profile, home in discovered:
+        name = str(profile or "default")
+        if name in seen:
+            continue
+        seen.add(name)
+        try:
+            status = data_paths.ensure_durable_data_root(Path(home))
+            entry = {
+                "profile": name,
+                "ready": status.ready,
+                "migrated": status.migrated,
+                "legacyPresent": status.legacy_present,
+                "conflict": status.conflict,
+            }
+        except OSError as error:
+            log.warning("yaoyao: storage migration failed for %s: %s", name, error)
+            entry = {
+                "profile": name,
+                "ready": False,
+                "migrated": False,
+                "legacyPresent": True,
+                "conflict": True,
+            }
+        ready = ready and bool(entry["ready"])
+        profiles.append(entry)
+    return {"ready": ready, "profiles": profiles}
+
+
 # ---------------------------------------------------------------------------
 # Request / response models
 # ---------------------------------------------------------------------------

+ 1 - 1
hermes-plugins/yaoyao/dashboard/poller.py

@@ -10,7 +10,7 @@ Profile isolation
 -----------------
 Each profile (agent) - ``default``, ``yaoyao``, ``yaoer``, ``gril``,
 ``succubus`` ... - has its OWN ``state.db`` and its OWN plugin data dir
-(``<profile_home>/plugins/yaoyao/data``). The poller keeps one watcher state
+(``<profile_home>/plugin-data/yaoyao``). The poller keeps one watcher state
 per profile: a separate RO connection to that profile's state.db, its own
 mtime watermark, and its own waterline. Files are ingested into that
 profile's store with ``owner_profile=<profile name>``.

+ 19 - 12
hermes-plugins/yaoyao/dashboard/store.py

@@ -6,10 +6,10 @@ storing the same image N times costs one copy on disk.
 
 Profile isolation
 -----------------
-Each profile (agent) gets its OWN data directory resolved from the profile's
-HERMES_HOME: ``<profile_home>/plugins/yaoyao/data/{index.sqlite3, objects/}``.
-The default profile uses ``~/.hermes/plugins/yaoyao/data/`` (back-compat with
-the pre-profile layout). A ``Store`` instance owns one connection per
+Each profile (agent) gets its OWN durable data directory:
+``<profile_home>/plugin-data/yaoyao/{index.sqlite3, objects/}``. Legacy
+``<profile_home>/plugins/yaoyao/data`` trees are moved there once before the
+store opens. A ``Store`` instance owns one connection per
 ``data_root``; the module keeps a small cache keyed by resolved path so
 callers passing the same ``data_root`` share a connection.
 
@@ -33,8 +33,10 @@ from typing import Any, Optional
 
 try:
     from .group_protocol import is_reserved_mention_alias
+    from .data_paths import DURABLE_DATA_SUBPATH, ensure_durable_data_root
 except ImportError:  # Loaded by the Dashboard plugin loader as a top-level module.
     from group_protocol import is_reserved_mention_alias
+    from data_paths import DURABLE_DATA_SUBPATH, ensure_durable_data_root
 
 log = logging.getLogger("yaoyao.store")
 
@@ -42,12 +44,16 @@ log = logging.getLogger("yaoyao.store")
 # Paths
 # ---------------------------------------------------------------------------
 PLUGIN_ROOT = Path(__file__).resolve().parent.parent  # ~/.hermes/plugins/yaoyao
-# Default profile's data dir (back-compat: <plugin>/data). Named profiles use
-# <profile_home>/plugins/yaoyao/data, resolved at call time.
-DEFAULT_DATA_ROOT = PLUGIN_ROOT / "data"
+# An installed plugin root is <profile_home>/plugins/yaoyao. Keeping this
+# derivation local also makes source-tree tests independent of the real home.
+DEFAULT_PROFILE_HOME = Path(
+    os.environ.get("HERMES_HOME") or PLUGIN_ROOT.parent.parent
+).expanduser().resolve()
+DEFAULT_DATA_PATH_STATUS = ensure_durable_data_root(DEFAULT_PROFILE_HOME)
+DEFAULT_DATA_ROOT = DEFAULT_DATA_PATH_STATUS.data_root
 
-# Subpath of a profile home where this plugin's per-profile data lives.
-PLUGIN_DATA_SUBPATH = Path("plugins") / "yaoyao" / "data"
+# Back-compatible public constant used by callers resolving named profiles.
+PLUGIN_DATA_SUBPATH = DURABLE_DATA_SUBPATH
 
 _SCHEMA = """
 CREATE TABLE IF NOT EXISTS objects (
@@ -689,8 +695,9 @@ def get_store(data_root: Optional[Path] = None) -> Store:
 def data_root_for_profile(profile: Optional[str]) -> Path:
     """Resolve the plugin data dir for a profile name.
 
-    ``default`` (or None) -> ``~/.hermes/plugins/yaoyao/data`` (back-compat).
-    Named profile -> ``<profile_home>/plugins/yaoyao/data``.
+    Legacy data is moved to ``<profile_home>/plugin-data/yaoyao`` before the
+    path is returned. A conflicting pair stays on the legacy tree so existing
+    data remains visible and the install coordinator can refuse replacement.
     """
     if not profile or profile == "default":
         return DEFAULT_DATA_ROOT
@@ -712,7 +719,7 @@ def data_root_for_profile(profile: Optional[str]) -> Path:
         except Exception:
             base = Path.home() / ".hermes"
         home = base / "profiles" / profile
-    return home / PLUGIN_DATA_SUBPATH
+    return ensure_durable_data_root(home).data_root
 
 
 # ---------------------------------------------------------------------------

+ 43 - 0
hermes-plugins/yaoyao/dashboard/tests/test_data_paths.py

@@ -0,0 +1,43 @@
+from pathlib import Path
+
+from data_paths import ensure_durable_data_root
+
+
+def test_moves_legacy_tree_to_durable_plugin_data(tmp_path: Path) -> None:
+    legacy = tmp_path / "plugins" / "yaoyao" / "data"
+    legacy.mkdir(parents=True)
+    (legacy / "index.sqlite3").write_bytes(b"database")
+
+    status = ensure_durable_data_root(tmp_path)
+
+    assert status.ready is True
+    assert status.migrated is True
+    assert status.data_root == tmp_path / "plugin-data" / "yaoyao"
+    assert (status.data_root / "index.sqlite3").read_bytes() == b"database"
+    assert not legacy.exists()
+
+
+def test_refuses_to_merge_two_nonempty_data_trees(tmp_path: Path) -> None:
+    legacy = tmp_path / "plugins" / "yaoyao" / "data"
+    durable = tmp_path / "plugin-data" / "yaoyao"
+    legacy.mkdir(parents=True)
+    durable.mkdir(parents=True)
+    (legacy / "legacy.db").write_bytes(b"legacy")
+    (durable / "current.db").write_bytes(b"current")
+
+    status = ensure_durable_data_root(tmp_path)
+
+    assert status.ready is False
+    assert status.conflict is True
+    assert status.data_root == legacy
+    assert (legacy / "legacy.db").exists()
+    assert (durable / "current.db").exists()
+
+
+def test_fresh_profile_uses_durable_location_without_creating_it(tmp_path: Path) -> None:
+    status = ensure_durable_data_root(tmp_path)
+
+    assert status.ready is True
+    assert status.migrated is False
+    assert status.data_root == tmp_path / "plugin-data" / "yaoyao"
+    assert not status.data_root.exists()

+ 4 - 3
hermes-plugins/yaoyao/dashboard/voice_store.py

@@ -7,9 +7,10 @@ Mirrors yaoyao-webui's tts-settings-store.ts and stt-settings-store.ts:
 - baseUrl presets (recently used base URLs per provider).
 - Secrets are masked as "[stored]" in public responses.
 
-Storage: a single JSON file at data/voice_providers.json (not SQLite - the data
-is small and write frequency is low). The structure matches yaoyao-webui's
-DB rows so the data is interchangeable.
+Storage: a single JSON file at
+``<profile_home>/plugin-data/yaoyao/voice_providers.json`` (not SQLite - the
+data is small and write frequency is low). The structure matches
+yaoyao-webui's DB rows so the data is interchangeable.
 """
 
 from __future__ import annotations

+ 5 - 0
hermes-plugins/yaoyao/plugin.yaml

@@ -0,0 +1,5 @@
+name: yaoyao
+version: "1.7.1"
+description: "YaoYao Dashboard extension for files, Agent identity, voice, and multi-device teams"
+author: samien
+

+ 10 - 1
src/server/app.ts

@@ -28,6 +28,7 @@ export interface ApplicationOptions {
   leases?: RealtimeLeaseStore
   pairings?: NodePairingStore
   uploads?: UploadStore
+  restartDashboard?: () => Promise<void>
 }
 
 export interface ApplicationRuntime {
@@ -138,7 +139,15 @@ export function createApplication(options: ApplicationOptions = {}): Application
     },
   }))
 
-  const router = createApiRouter({ config, csrf, upstream, leases, pairings, uploads })
+  const router = createApiRouter({
+    config,
+    csrf,
+    upstream,
+    leases,
+    pairings,
+    uploads,
+    restartDashboard: options.restartDashboard,
+  })
   app.use(router.routes())
   app.use(router.allowedMethods({ throw: false }))
   app.use(async (ctx, next) => {

+ 21 - 0
src/server/config.ts

@@ -18,8 +18,11 @@ export interface ServerConfig {
   insecureLan: boolean
   production: boolean
   superviseDashboard?: boolean
+  yaoyaoPluginSource?: string
 }
 
+export const DEFAULT_YAOYAO_PLUGIN_SOURCE = 'https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao'
+
 function flag(value: string | undefined): boolean {
   return value === '1' || value?.toLowerCase() === 'true'
 }
@@ -44,6 +47,22 @@ function parseUpstream(value: string | undefined): URL {
   return upstream
 }
 
+function parsePluginSource(value: string | undefined): string {
+  const source = value?.trim() || DEFAULT_YAOYAO_PLUGIN_SOURCE
+  if (source.length > 2_048 || /[\u0000-\u001f\u007f]/.test(source)) {
+    throw new Error('HERMES_YAOYAO_PLUGIN_SOURCE is invalid')
+  }
+  if (source.startsWith('https://') || source.startsWith('http://')) {
+    const url = new URL(source)
+    if (url.username || url.password) {
+      throw new Error('HERMES_YAOYAO_PLUGIN_SOURCE must not contain credentials')
+    }
+  } else if (!source.startsWith('git@') && !source.startsWith('ssh://')) {
+    throw new Error('HERMES_YAOYAO_PLUGIN_SOURCE must be an HTTPS or SSH Git source')
+  }
+  return source
+}
+
 function normalizeConfiguredHost(raw: string): string {
   const value = raw.trim().toLowerCase()
   if (!value) return ''
@@ -99,6 +118,7 @@ export function loadServerConfig(
   const allowInsecureLan = flag(env.HERMES_YAOYAO_ALLOW_INSECURE_LAN)
   const production = env.NODE_ENV === 'production'
   const superviseDashboard = flag(env.HERMES_YAOYAO_SUPERVISE_DASHBOARD)
+  const yaoyaoPluginSource = parsePluginSource(env.HERMES_YAOYAO_PLUGIN_SOURCE)
   const insecureLan = !tlsCert && !isLoopbackHost(host)
   if (production && insecureLan && !allowInsecureLan) {
     throw new Error(
@@ -127,5 +147,6 @@ export function loadServerConfig(
     insecureLan,
     production,
     superviseDashboard,
+    yaoyaoPluginSource,
   }
 }

+ 17 - 0
src/server/dashboardSupervisor.ts

@@ -6,6 +6,7 @@ export const DEFAULT_DASHBOARD_USERNAME = 'admin'
 export const DEFAULT_DASHBOARD_PASSWORD = 'admin'
 const DASHBOARD_PORT = 9119
 const SUPERVISION_INTERVAL_MS = 5_000
+const RESTART_READY_TIMEOUT_MS = 15_000
 
 type Run = (args: readonly string[]) => string
 type Launch = (args: readonly string[]) => void
@@ -47,6 +48,10 @@ function portIsListening(host: string, port: number): Promise<boolean> {
   })
 }
 
+function delay(milliseconds: number): Promise<void> {
+  return new Promise((resolve) => setTimeout(resolve, milliseconds))
+}
+
 function passwordHash(password: string): string {
   const salt = randomBytes(16)
   const derived = scryptSync(password, salt, 32, { N: 2 ** 14, r: 8, p: 1, maxmem: 64 * 1024 * 1024 })
@@ -86,6 +91,18 @@ export class DashboardSupervisor {
     this.timer = undefined
   }
 
+  async restart(): Promise<void> {
+    while (this.checking) await delay(25)
+    this.run(['dashboard', '--stop'])
+    await this.checkNow()
+    const deadline = Date.now() + RESTART_READY_TIMEOUT_MS
+    while (Date.now() < deadline) {
+      if (await this.probe('127.0.0.1', DASHBOARD_PORT)) return
+      await delay(100)
+    }
+    throw new Error('Hermes Dashboard did not return on 9119 after restart')
+  }
+
   async checkNow(): Promise<void> {
     if (this.checking) return
     this.checking = true

+ 11 - 5
src/server/index.ts

@@ -3,14 +3,20 @@ import { resolve } from 'node:path'
 import send from 'koa-send'
 import serve from 'koa-static'
 import { createApplication, createNodeServer } from './app.js'
-import { isLoopbackHost } from './config.js'
+import { isLoopbackHost, loadServerConfig } from './config.js'
 import { DashboardSupervisor } from './dashboardSupervisor.js'
 
-const runtime = createApplication()
-const nodeRuntime = createNodeServer(runtime)
-const dashboardSupervisor = runtime.config.superviseDashboard
-  ? new DashboardSupervisor({ allowLan: !isLoopbackHost(runtime.config.host) })
+const config = loadServerConfig()
+const dashboardSupervisor = config.superviseDashboard
+  ? new DashboardSupervisor({ allowLan: !isLoopbackHost(config.host) })
   : undefined
+const runtime = createApplication({
+  config,
+  ...(dashboardSupervisor
+    ? { restartDashboard: () => dashboardSupervisor.restart() }
+    : {}),
+})
+const nodeRuntime = createNodeServer(runtime)
 let closeFrontend = async (): Promise<void> => undefined
 
 if (runtime.config.production) {

+ 98 - 1
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 { isLoopbackUpstream } from './config.js'
+import { DEFAULT_YAOYAO_PLUGIN_SOURCE, isLoopbackUpstream } from './config.js'
 import { HttpError } from './errors.js'
 import { canonicalEpoch, groupCursor, type RealtimeChannel, RealtimeLeaseStore } from './leases.js'
 import {
@@ -36,6 +36,7 @@ export interface RouteDependencies {
   leases: RealtimeLeaseStore
   pairings: NodePairingStore
   uploads: UploadStore
+  restartDashboard?: () => Promise<void>
 }
 
 function body(ctx: Koa.Context): JsonObject {
@@ -78,6 +79,14 @@ function parseJson(response: UpstreamResponse): JsonObject {
   }
 }
 
+function parseJsonValue(response: UpstreamResponse): unknown {
+  try {
+    return JSON.parse(response.body.toString('utf8')) as unknown
+  } catch {
+    throw new HttpError(502, 'Hermes returned invalid JSON', 'invalid_upstream_json')
+  }
+}
+
 function requireSuccess(response: UpstreamResponse): JsonObject {
   if (response.status < 200 || response.status >= 300) {
     const decoded = decodeUpstreamError(response)
@@ -848,6 +857,94 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       }
     })
   })
+  router.post('/api/app/plugins/yaoyao/install', async (ctx) => {
+    await withJar(ctx, async (jar) => {
+      await requireGatewayAuthentication(ctx, dependencies, jar)
+      const pluginSource = dependencies.config.yaoyaoPluginSource
+        ?? DEFAULT_YAOYAO_PLUGIN_SOURCE
+      const request = body(ctx)
+      if (request.force !== undefined && typeof request.force !== 'boolean') {
+        throw new HttpError(400, 'force must be a boolean', 'invalid_request')
+      }
+
+      const manifestsResponse = await dependencies.upstream.request(
+        '/api/dashboard/plugins',
+        jar,
+      )
+      if (manifestsResponse.status < 200 || manifestsResponse.status >= 300) {
+        sendUpstreamResponse(ctx, manifestsResponse, jar)
+        return
+      }
+      const manifests = parseJsonValue(manifestsResponse)
+      if (!Array.isArray(manifests)) {
+        throw new HttpError(502, 'Hermes returned an invalid plugin list', 'invalid_upstream_json')
+      }
+      const installed = manifests.some((entry) => (
+        entry && typeof entry === 'object' && !Array.isArray(entry)
+        && (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',
+          )
+        }
+      }
+
+      const force = request.force === true
+      if (installed && !force) {
+        throw new HttpError(
+          409,
+          '夭夭已经安装;升级时必须明确传入 force=true',
+          'yaoyao_force_required',
+        )
+      }
+      const installResponse = await dependencies.upstream.request(
+        '/api/dashboard/agent-plugins/install',
+        jar,
+        {
+          method: 'POST',
+          body: {
+            identifier: pluginSource,
+            force,
+            enable: true,
+          },
+          clientAddress: ctx.req.socket.remoteAddress,
+        },
+      )
+      if (installResponse.status < 200 || installResponse.status >= 300) {
+        sendUpstreamResponse(ctx, installResponse, jar)
+        return
+      }
+      const installedResult = parseJson(installResponse)
+      let restarted = false
+      if (dependencies.restartDashboard) {
+        await dependencies.restartDashboard()
+        restarted = true
+      }
+      json(ctx, 200, {
+        ...installedResult,
+        source: pluginSource,
+        restarted,
+        restartRequired: !restarted,
+      })
+    })
+  })
   router.post('/api/app/realtime-leases', async (ctx) => {
     await withJar(ctx, (jar) => issueLease(ctx, dependencies, jar))
   })

+ 133 - 0
tests/server/app.test.ts

@@ -115,6 +115,139 @@ describe('8800 BFF', () => {
     runtimes.push(restarted)
     expect(restarted.csrf.verify(cookies, bootstrap.body.csrfToken)).toBe(true)
   })
+
+  it('installs a fresh Yaoyao plugin through authenticated Hermes and restarts supervised 9119', async () => {
+    const records: RecordedRequest[] = []
+    const baseGateway = fakeGateway(records)
+    const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
+      const url = new URL(input instanceof Request ? input.url : String(input))
+      if (url.pathname === '/api/dashboard/plugins') return jsonResponse([])
+      if (url.pathname === '/api/dashboard/agent-plugins/install') {
+        const rawBody = typeof init?.body === 'string' ? init.body : '{}'
+        records.push({
+          path: url.pathname,
+          method: init?.method ?? 'GET',
+          search: url.searchParams,
+          headers: new Headers(init?.headers),
+          body: JSON.parse(rawBody),
+        })
+        return jsonResponse({ ok: true, plugin_name: 'yaoyao', enabled: true })
+      }
+      return baseGateway(input, init)
+    }) as typeof fetch
+    let restarts = 0
+    const runtime = createApplication({
+      config: makeConfig(),
+      fetchImpl,
+      restartDashboard: async () => { restarts += 1 },
+    })
+    runtimes.push(runtime)
+    const bootstrap = await request(runtime.app.callback())
+      .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+
+    const response = await request(runtime.app.callback())
+      .post('/api/app/plugins/yaoyao/install')
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Cookie', cookieHeader(bootstrap))
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({ force: false })
+      .expect(200)
+
+    const install = records.find((entry) => entry.path === '/api/dashboard/agent-plugins/install')
+    expect(install?.body).toEqual({
+      identifier: 'https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao',
+      force: false,
+      enable: true,
+    })
+    expect(restarts).toBe(1)
+    expect(response.body).toMatchObject({
+      ok: true,
+      plugin_name: 'yaoyao',
+      restarted: true,
+      restartRequired: false,
+    })
+  })
+
+  it('refuses to replace an existing Yaoyao before durable storage is ready', async () => {
+    const records: RecordedRequest[] = []
+    const baseGateway = fakeGateway(records)
+    const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
+      const url = new URL(input instanceof Request ? input.url : String(input))
+      if (url.pathname === '/api/dashboard/plugins') {
+        return jsonResponse([{ name: 'yaoyao', version: '1.7.1' }])
+      }
+      if (url.pathname === '/api/plugins/yaoyao/maintenance/storage') {
+        return jsonResponse({ detail: 'Not Found' }, { status: 404 })
+      }
+      return baseGateway(input, init)
+    }) as typeof fetch
+    const runtime = createApplication({ config: makeConfig(), fetchImpl })
+    runtimes.push(runtime)
+    const bootstrap = await request(runtime.app.callback())
+      .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+
+    const response = await request(runtime.app.callback())
+      .post('/api/app/plugins/yaoyao/install')
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Cookie', cookieHeader(bootstrap))
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({ force: true })
+      .expect(409)
+
+    expect(response.body.code).toBe('yaoyao_storage_migration_required')
+    expect(records.some((entry) => entry.path === '/api/dashboard/agent-plugins/install')).toBe(false)
+  })
+
+  it('upgrades an existing Yaoyao only after storage readiness and explicit force', async () => {
+    const records: RecordedRequest[] = []
+    const baseGateway = fakeGateway(records)
+    const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
+      const url = new URL(input instanceof Request ? input.url : String(input))
+      if (url.pathname === '/api/dashboard/plugins') {
+        return jsonResponse([{ name: 'yaoyao', version: '1.7.1' }])
+      }
+      if (url.pathname === '/api/plugins/yaoyao/maintenance/storage') {
+        return jsonResponse({ ready: true, profiles: [{ profile: 'default', ready: true }] })
+      }
+      if (url.pathname === '/api/dashboard/agent-plugins/install') {
+        const rawBody = typeof init?.body === 'string' ? init.body : '{}'
+        records.push({
+          path: url.pathname,
+          method: init?.method ?? 'GET',
+          search: url.searchParams,
+          headers: new Headers(init?.headers),
+          body: JSON.parse(rawBody),
+        })
+        return jsonResponse({ ok: true, plugin_name: 'yaoyao', enabled: true })
+      }
+      return baseGateway(input, init)
+    }) as typeof fetch
+    const runtime = createApplication({ config: makeConfig(), fetchImpl })
+    runtimes.push(runtime)
+    const bootstrap = await request(runtime.app.callback())
+      .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const common = request(runtime.app.callback())
+      .post('/api/app/plugins/yaoyao/install')
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Cookie', cookieHeader(bootstrap))
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+
+    await common.send({ force: false }).expect(409)
+    await request(runtime.app.callback())
+      .post('/api/app/plugins/yaoyao/install')
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Cookie', cookieHeader(bootstrap))
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({ force: true })
+      .expect(200)
+
+    const install = records.find((entry) => entry.path === '/api/dashboard/agent-plugins/install')
+    expect(install?.body).toMatchObject({ force: true, enable: true })
+  })
   it('serves authenticated historical media only from the configured root', async () => {
     const config = makeConfig()
     writeFileSync(join(config.mediaRoot, '报告.txt'), '历史文件内容')

+ 20 - 1
tests/server/dashboardSupervisor.test.ts

@@ -28,7 +28,10 @@ function supervisor(overrides: {
       }
       return ''
     },
-    launch(args) { launches.push([...args]) },
+    launch(args) {
+      launches.push([...args])
+      running = true
+    },
     probe: async () => running,
     log: () => undefined,
   })
@@ -68,4 +71,20 @@ describe('DashboardSupervisor', () => {
     expect(value.calls).toContainEqual(['dashboard', '--stop'])
     expect(value.launches).toEqual([['dashboard', '--host', '0.0.0.0', '--no-open']])
   })
+
+  it('explicitly restarts a healthy dashboard and waits for 9119 to return', async () => {
+    const value = supervisor({
+      running: true,
+      values: {
+        'dashboard.basic_auth.username': 'operator',
+        'dashboard.basic_auth.password_hash': 'scrypt$existing',
+        'dashboard.basic_auth.secret': 'configured-secret',
+      },
+    })
+
+    await value.instance.restart()
+
+    expect(value.calls).toContainEqual(['dashboard', '--stop'])
+    expect(value.launches).toEqual([['dashboard', '--host', '127.0.0.1', '--no-open']])
+  })
 })

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

@@ -107,5 +107,14 @@ describe('server security boundary', () => {
     const value = loadServerConfig({})
     expect(value.host).toBe('127.0.0.1')
     expect(value.insecureLan).toBe(false)
+    expect(value.yaoyaoPluginSource).toBe(
+      'https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao',
+    )
+  })
+
+  it('rejects credentials embedded in the configured Yaoyao plugin source', () => {
+    expect(() => loadServerConfig({
+      HERMES_YAOYAO_PLUGIN_SOURCE: 'https://user:secret@git.example/yaoyao.git#hermes-plugins/yaoyao',
+    })).toThrow(/must not contain credentials/)
   })
 })