Explorar o código

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

LSamien hai 2 meses
pai
achega
851582df4f

+ 1 - 0
.dockerignore

@@ -23,6 +23,7 @@ docker.env
 hermes-plugins/*
 hermes-plugins/*
 !hermes-plugins/yaoyao
 !hermes-plugins/yaoyao
 hermes-plugins/yaoyao/*
 hermes-plugins/yaoyao/*
+!hermes-plugins/yaoyao/plugin.yaml
 !hermes-plugins/yaoyao/dashboard
 !hermes-plugins/yaoyao/dashboard
 !hermes-plugins/yaoyao/dashboard/**
 !hermes-plugins/yaoyao/dashboard/**
 docs
 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.
 # Keep the Dashboard plugin in the offline image as an installation payload.
 # Hermes itself owns plugin discovery, configuration, and restart, so this is
 # Hermes itself owns plugin discovery, configuration, and restart, so this is
 # deliberately not copied into the Web container's runtime data directory.
 # 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
 USER node
 
 

+ 30 - 0
README.md

@@ -5,6 +5,10 @@
 仓库同时归档了夭夭的 Hermes Dashboard 插件,位于
 仓库同时归档了夭夭的 Hermes Dashboard 插件,位于
 [`hermes-plugins/yaoyao/dashboard`](hermes-plugins/yaoyao/dashboard)。Web 工作台和该插件是两个独立部署单元:前者运行在 `8800`,后者由已运行的 Hermes Dashboard 在 `9119` 加载。
 [`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`**。
 当前发布版本:**Git `v0.1.1` / 夭夭 Web `0.1.1` / Hermes Dashboard 插件 `1.7.1`**。
 
 
 需要由自动化 Agent 部署或升级时,请直接使用 [Agent 安装手册](docs/agent-install.md)。其中包含固定版本校验、备份、同步、单一 Dashboard 重载与验证步骤。
 需要由自动化 Agent 部署或升级时,请直接使用 [Agent 安装手册](docs/agent-install.md)。其中包含固定版本校验、备份、同步、单一 Dashboard 重载与验证步骤。
@@ -77,6 +81,32 @@ tail -n 100 ~/.hermes/logs/gui.log
 
 
 插件的归档数据与运行状态仍位于 Hermes 数据目录中,不在此仓库的 `hermes-plugins/` 中;升级插件前的备份也不应提交到 Git。
 插件的归档数据与运行状态仍位于 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
 ```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/` 等运行时内容。归档数据库和本机状态不属于插件发布物。
 不要提交、复制或恢复 `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 监督者
 ## 3. 由 8800 服务担任唯一 Dashboard 监督者
 
 
 插件文件只有在 Dashboard 重载后才会生效。安装本项目的受管服务后,8800 服务是 Dashboard 的唯一持续监督者;不要保留其他 Dashboard LaunchAgent 或手动常驻进程。
 插件文件只有在 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 pathlib import Path
 from typing import Callable, Iterator, Mapping
 from typing import Callable, Iterator, Mapping
 
 
+from .data_paths import ensure_durable_data_root
 from .group_protocol import (
 from .group_protocol import (
     ALL_MENTION_ALIASES,
     ALL_MENTION_ALIASES,
     CONTEXT_CHARACTER_BUDGET,
     CONTEXT_CHARACTER_BUDGET,
@@ -329,7 +330,7 @@ class GroupStore:
     ) -> "GroupStore":
     ) -> "GroupStore":
         hermes_home = Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes")
         hermes_home = Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes")
         return cls(
         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,
             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 store  # noqa: E402
 import poller  # noqa: E402
 import poller  # noqa: E402
 import voice_store  # noqa: E402
 import voice_store  # noqa: E402
+import data_paths  # noqa: E402
 
 
 
 
 def _profile_home(profile: Optional[str]) -> Path:
 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)")
     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
 # Request / response models
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------

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

@@ -10,7 +10,7 @@ Profile isolation
 -----------------
 -----------------
 Each profile (agent) - ``default``, ``yaoyao``, ``yaoer``, ``gril``,
 Each profile (agent) - ``default``, ``yaoyao``, ``yaoer``, ``gril``,
 ``succubus`` ... - has its OWN ``state.db`` and its OWN plugin data dir
 ``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
 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
 mtime watermark, and its own waterline. Files are ingested into that
 profile's store with ``owner_profile=<profile name>``.
 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
 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
 ``data_root``; the module keeps a small cache keyed by resolved path so
 callers passing the same ``data_root`` share a connection.
 callers passing the same ``data_root`` share a connection.
 
 
@@ -33,8 +33,10 @@ from typing import Any, Optional
 
 
 try:
 try:
     from .group_protocol import is_reserved_mention_alias
     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.
 except ImportError:  # Loaded by the Dashboard plugin loader as a top-level module.
     from group_protocol import is_reserved_mention_alias
     from group_protocol import is_reserved_mention_alias
+    from data_paths import DURABLE_DATA_SUBPATH, ensure_durable_data_root
 
 
 log = logging.getLogger("yaoyao.store")
 log = logging.getLogger("yaoyao.store")
 
 
@@ -42,12 +44,16 @@ log = logging.getLogger("yaoyao.store")
 # Paths
 # Paths
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 PLUGIN_ROOT = Path(__file__).resolve().parent.parent  # ~/.hermes/plugins/yaoyao
 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 = """
 _SCHEMA = """
 CREATE TABLE IF NOT EXISTS objects (
 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:
 def data_root_for_profile(profile: Optional[str]) -> Path:
     """Resolve the plugin data dir for a profile name.
     """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":
     if not profile or profile == "default":
         return DEFAULT_DATA_ROOT
         return DEFAULT_DATA_ROOT
@@ -712,7 +719,7 @@ def data_root_for_profile(profile: Optional[str]) -> Path:
         except Exception:
         except Exception:
             base = Path.home() / ".hermes"
             base = Path.home() / ".hermes"
         home = base / "profiles" / profile
         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).
 - baseUrl presets (recently used base URLs per provider).
 - Secrets are masked as "[stored]" in public responses.
 - 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
 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
   leases?: RealtimeLeaseStore
   pairings?: NodePairingStore
   pairings?: NodePairingStore
   uploads?: UploadStore
   uploads?: UploadStore
+  restartDashboard?: () => Promise<void>
 }
 }
 
 
 export interface ApplicationRuntime {
 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.routes())
   app.use(router.allowedMethods({ throw: false }))
   app.use(router.allowedMethods({ throw: false }))
   app.use(async (ctx, next) => {
   app.use(async (ctx, next) => {

+ 21 - 0
src/server/config.ts

@@ -18,8 +18,11 @@ export interface ServerConfig {
   insecureLan: boolean
   insecureLan: boolean
   production: boolean
   production: boolean
   superviseDashboard?: 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 {
 function flag(value: string | undefined): boolean {
   return value === '1' || value?.toLowerCase() === 'true'
   return value === '1' || value?.toLowerCase() === 'true'
 }
 }
@@ -44,6 +47,22 @@ function parseUpstream(value: string | undefined): URL {
   return upstream
   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 {
 function normalizeConfiguredHost(raw: string): string {
   const value = raw.trim().toLowerCase()
   const value = raw.trim().toLowerCase()
   if (!value) return ''
   if (!value) return ''
@@ -99,6 +118,7 @@ export function loadServerConfig(
   const allowInsecureLan = flag(env.HERMES_YAOYAO_ALLOW_INSECURE_LAN)
   const allowInsecureLan = flag(env.HERMES_YAOYAO_ALLOW_INSECURE_LAN)
   const production = env.NODE_ENV === 'production'
   const production = env.NODE_ENV === 'production'
   const superviseDashboard = flag(env.HERMES_YAOYAO_SUPERVISE_DASHBOARD)
   const superviseDashboard = flag(env.HERMES_YAOYAO_SUPERVISE_DASHBOARD)
+  const yaoyaoPluginSource = parsePluginSource(env.HERMES_YAOYAO_PLUGIN_SOURCE)
   const insecureLan = !tlsCert && !isLoopbackHost(host)
   const insecureLan = !tlsCert && !isLoopbackHost(host)
   if (production && insecureLan && !allowInsecureLan) {
   if (production && insecureLan && !allowInsecureLan) {
     throw new Error(
     throw new Error(
@@ -127,5 +147,6 @@ export function loadServerConfig(
     insecureLan,
     insecureLan,
     production,
     production,
     superviseDashboard,
     superviseDashboard,
+    yaoyaoPluginSource,
   }
   }
 }
 }

+ 17 - 0
src/server/dashboardSupervisor.ts

@@ -6,6 +6,7 @@ export const DEFAULT_DASHBOARD_USERNAME = 'admin'
 export const DEFAULT_DASHBOARD_PASSWORD = 'admin'
 export const DEFAULT_DASHBOARD_PASSWORD = 'admin'
 const DASHBOARD_PORT = 9119
 const DASHBOARD_PORT = 9119
 const SUPERVISION_INTERVAL_MS = 5_000
 const SUPERVISION_INTERVAL_MS = 5_000
+const RESTART_READY_TIMEOUT_MS = 15_000
 
 
 type Run = (args: readonly string[]) => string
 type Run = (args: readonly string[]) => string
 type Launch = (args: readonly string[]) => void
 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 {
 function passwordHash(password: string): string {
   const salt = randomBytes(16)
   const salt = randomBytes(16)
   const derived = scryptSync(password, salt, 32, { N: 2 ** 14, r: 8, p: 1, maxmem: 64 * 1024 * 1024 })
   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
     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> {
   async checkNow(): Promise<void> {
     if (this.checking) return
     if (this.checking) return
     this.checking = true
     this.checking = true

+ 11 - 5
src/server/index.ts

@@ -3,14 +3,20 @@ import { resolve } from 'node:path'
 import send from 'koa-send'
 import send from 'koa-send'
 import serve from 'koa-static'
 import serve from 'koa-static'
 import { createApplication, createNodeServer } from './app.js'
 import { createApplication, createNodeServer } from './app.js'
-import { isLoopbackHost } from './config.js'
+import { isLoopbackHost, loadServerConfig } from './config.js'
 import { DashboardSupervisor } from './dashboardSupervisor.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
   : undefined
+const runtime = createApplication({
+  config,
+  ...(dashboardSupervisor
+    ? { restartDashboard: () => dashboardSupervisor.restart() }
+    : {}),
+})
+const nodeRuntime = createNodeServer(runtime)
 let closeFrontend = async (): Promise<void> => undefined
 let closeFrontend = async (): Promise<void> => undefined
 
 
 if (runtime.config.production) {
 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 { lookup as mimeLookup } from 'mime-types'
 import { isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '../shared/types.js'
 import { isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '../shared/types.js'
 import type { ServerConfig } from './config.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 { HttpError } from './errors.js'
 import { canonicalEpoch, groupCursor, type RealtimeChannel, RealtimeLeaseStore } from './leases.js'
 import { canonicalEpoch, groupCursor, type RealtimeChannel, RealtimeLeaseStore } from './leases.js'
 import {
 import {
@@ -36,6 +36,7 @@ export interface RouteDependencies {
   leases: RealtimeLeaseStore
   leases: RealtimeLeaseStore
   pairings: NodePairingStore
   pairings: NodePairingStore
   uploads: UploadStore
   uploads: UploadStore
+  restartDashboard?: () => Promise<void>
 }
 }
 
 
 function body(ctx: Koa.Context): JsonObject {
 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 {
 function requireSuccess(response: UpstreamResponse): JsonObject {
   if (response.status < 200 || response.status >= 300) {
   if (response.status < 200 || response.status >= 300) {
     const decoded = decodeUpstreamError(response)
     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) => {
   router.post('/api/app/realtime-leases', async (ctx) => {
     await withJar(ctx, (jar) => issueLease(ctx, dependencies, jar))
     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)
     runtimes.push(restarted)
     expect(restarted.csrf.verify(cookies, bootstrap.body.csrfToken)).toBe(true)
     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 () => {
   it('serves authenticated historical media only from the configured root', async () => {
     const config = makeConfig()
     const config = makeConfig()
     writeFileSync(join(config.mediaRoot, '报告.txt'), '历史文件内容')
     writeFileSync(join(config.mediaRoot, '报告.txt'), '历史文件内容')

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

@@ -28,7 +28,10 @@ function supervisor(overrides: {
       }
       }
       return ''
       return ''
     },
     },
-    launch(args) { launches.push([...args]) },
+    launch(args) {
+      launches.push([...args])
+      running = true
+    },
     probe: async () => running,
     probe: async () => running,
     log: () => undefined,
     log: () => undefined,
   })
   })
@@ -68,4 +71,20 @@ describe('DashboardSupervisor', () => {
     expect(value.calls).toContainEqual(['dashboard', '--stop'])
     expect(value.calls).toContainEqual(['dashboard', '--stop'])
     expect(value.launches).toEqual([['dashboard', '--host', '0.0.0.0', '--no-open']])
     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({})
     const value = loadServerConfig({})
     expect(value.host).toBe('127.0.0.1')
     expect(value.host).toBe('127.0.0.1')
     expect(value.insecureLan).toBe(false)
     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/)
   })
   })
 })
 })