Pārlūkot izejas kodu

Supervise Dashboard lifecycle and default Web to loopback

samien 2 mēneši atpakaļ
vecāks
revīzija
70b1e89f11

+ 2 - 0
.env.example

@@ -5,6 +5,8 @@ HERMES_YAOYAO_UPSTREAM=http://127.0.0.1:9119
 HERMES_YAOYAO_ALLOWED_HOSTS=
 # For LAN access set HOST=0.0.0.0 and either configure TLS or explicitly enable trusted-LAN HTTP below.
 HERMES_YAOYAO_ALLOW_INSECURE_LAN=0
+# The managed LaunchAgent sets this to 1 and keeps the local Hermes Dashboard on 9119 available.
+HERMES_YAOYAO_SUPERVISE_DASHBOARD=0
 # HERMES_YAOYAO_TLS_CERT=/absolute/path/to/cert.pem
 # HERMES_YAOYAO_TLS_KEY=/absolute/path/to/key.pem
 # HERMES_YAOYAO_HOME=/Users/you/.hermes-yaoyao

+ 20 - 3
README.md

@@ -1,10 +1,14 @@
 # 夭夭 Web
 
-夭夭 Web 是一个独立的本地 Web 工作台,通过端口 `8800` 连接现有 Hermes Dashboard/Gateway `9119`,提供普通聊天、群聊、文件库和产物浏览。它不会启动、停止或监督 Hermes。
+夭夭 Web 是一个独立的本地 Web 工作台,通过端口 `8800` 连接 Hermes Dashboard/Gateway `9119`,提供普通聊天、群聊、文件库和产物浏览。通过本项目安装的 LaunchAgent 会持续监督 `9119`:缺失时自动配置 Dashboard 认证并启动它。
 
 仓库同时归档了夭夭的 Hermes Dashboard 插件,位于
 [`hermes-plugins/yaoyao/dashboard`](hermes-plugins/yaoyao/dashboard)。Web 工作台和该插件是两个独立部署单元:前者运行在 `8800`,后者由已运行的 Hermes Dashboard 在 `9119` 加载。
 
+当前发布版本:**Git `v0.1.0` / 夭夭 Web `0.1.0` / Hermes Dashboard 插件 `1.6.1`**。
+
+需要由自动化 Agent 部署或升级时,请直接使用 [Agent 安装手册](docs/agent-install.md)。其中包含固定版本校验、备份、同步、单一 Dashboard 重载与验证步骤。
+
 ## 安装 Hermes Dashboard 插件
 
 前提是目标机器已安装并可运行 Hermes。插件目录会被 Hermes 以路径加载,因此请保留下面的目录层级,不要只复制其中的 Python 文件或 `dist/`。
@@ -37,7 +41,7 @@ rsync -a --delete \
 
 `rsync --delete` 只会删除目标 `dashboard/` 目录中仓库未提供的旧插件文件,不会触及其他 Hermes 插件或数据;如需保留本机额外文件,请先移出该目录或不要使用 `--delete`。
 
-安装后重启 Dashboard,使运行中的 `9119` 进程重新加载插件:
+安装后停止 Dashboard,使运行中的 `9119` 进程重新加载插件;已安装的夭夭 Web LaunchAgent 会在下一轮检查中自动按其配置重启它:
 
 ```bash
 hermes dashboard --stop
@@ -62,7 +66,18 @@ cp .env.example .env
 npm run dev
 ```
 
-默认上游是 `http://127.0.0.1:9119`,服务默认只监听本机。局域网 HTTP 只适用于可信网络,必须同时设置:
+## 网络暴露:9119 与 8800 分别控制
+
+默认情况下,两个端口都**不开放局域网**:Hermes Dashboard `9119` 和夭夭 Web `8800` 都只监听 `127.0.0.1`。通过本项目安装的受管服务开启局域网时,两个端口会一起开放。
+
+| 端口 | 服务 | 默认 | 单独开启局域网 |
+| --- | --- | --- | --- |
+| `9119` | Hermes Dashboard | `127.0.0.1` | 由夭夭 Web 受管服务监督;首次缺失配置时自动创建 `admin/admin` 登录并启动 |
+| `8800` | 夭夭 Web | `127.0.0.1` | 显式设置 `HERMES_YAOYAO_HOST=0.0.0.0` 后与 9119 一起开放 |
+
+夭夭 Web 默认上游仍是 `http://127.0.0.1:9119`。受管服务的局域网开关会同时让它监听局域网地址,并让其启动的 Dashboard `9119` 监听局域网地址。
+
+仅在可信局域网中以 HTTP 开放受管服务时,必须同时设置:
 
 ```bash
 HERMES_YAOYAO_HOST=0.0.0.0
@@ -71,6 +86,8 @@ HERMES_YAOYAO_ALLOW_INSECURE_LAN=1
 
 生产局域网使用建议同时配置 `HERMES_YAOYAO_TLS_CERT` 和 `HERMES_YAOYAO_TLS_KEY`。
 
+受管服务会在 `dashboard.basic_auth` 未配置时创建用户名 `admin`、密码 `admin` 的 scrypt 哈希和随机会话签名 `secret`。这是已知默认凭据,首次登录后必须立即修改。具体步骤见 [Agent 安装手册](docs/agent-install.md#受管-9119-的默认认证与持续监督)。Dashboard 的 `--insecure` 参数不会关闭认证;不要并行安装其他 Dashboard 监督器。
+
 ## 构建与运行
 
 ```bash

+ 2 - 0
bin/hermes-yaoyao.mjs

@@ -40,6 +40,7 @@ function environment() {
     'HERMES_YAOYAO_TLS_CERT',
     'HERMES_YAOYAO_TLS_KEY',
     'HERMES_YAOYAO_ALLOW_INSECURE_LAN',
+    'HERMES_YAOYAO_SUPERVISE_DASHBOARD',
   ]
   return Object.fromEntries(allowed.flatMap(key => process.env[key] ? [[key, process.env[key]]] : []))
 }
@@ -53,6 +54,7 @@ function plist() {
     HERMES_YAOYAO_HOST: '127.0.0.1',
     HERMES_YAOYAO_PORT: '8800',
     HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:9119',
+    HERMES_YAOYAO_SUPERVISE_DASHBOARD: '1',
     ...environment(),
   }
   const envXml = Object.entries(envEntries).map(([key, value]) => (

+ 163 - 0
docs/agent-install.md

@@ -0,0 +1,163 @@
+# 夭夭 Agent 安装手册
+
+适用于需要部署或升级夭夭 Web 与 Hermes Dashboard 插件的自动化 Agent。
+
+## 发布版本
+
+| 项目 | 版本 | 校验位置 |
+| --- | --- | --- |
+| Git 发布标签 | `v0.1.0` | `git describe --tags --exact-match HEAD` |
+| 夭夭 Web | `0.1.0` | `package.json` 的 `version` |
+| Hermes Dashboard 插件 | `1.6.1` | `hermes-plugins/yaoyao/dashboard/manifest.json` 的 `version` |
+
+以下步骤以仓库根目录为工作目录。不要使用浮动分支替代发布标签。
+
+## 网络默认值与局域网策略
+
+默认情况下,两个服务都**不开启局域网访问**。受管 8800 服务启用局域网时,会将两个端口一起开放:
+
+| 端口 | 服务 | 默认监听地址 | 开启局域网的责任边界 |
+| --- | --- | --- | --- |
+| `9119` | Hermes Dashboard | `127.0.0.1` | 由 8800 服务持续监督;无配置时创建 `admin/admin` 认证并启动 |
+| `8800` | 夭夭 Web | `127.0.0.1` | 显式设置 `HERMES_YAOYAO_HOST=0.0.0.0` 后与 9119 一起开放 |
+
+最小暴露策略是保持两个端口仅本机监听。受管服务不支持“仅开放 8800”:用户明确允许局域网后,它会同时以局域网地址启动 8800 和 9119。
+
+若用户明确要求开放可信局域网 HTTP,Agent 必须在安装或重启夭夭 Web 前设置:
+
+```bash
+export HERMES_YAOYAO_HOST=0.0.0.0
+export HERMES_YAOYAO_ALLOW_INSECURE_LAN=1
+```
+
+生产环境优先设置 `HERMES_YAOYAO_TLS_CERT` 和 `HERMES_YAOYAO_TLS_KEY`。该开关会将受管 9119 一起绑定至 `0.0.0.0`;`hermes dashboard --insecure` 不会关闭认证,不能将它当成局域网开关。
+
+## 受管 9119 的默认认证与持续监督
+
+夭夭 Web LaunchAgent 默认启用 `HERMES_YAOYAO_SUPERVISE_DASHBOARD=1`。它每 5 秒检查本机 `9119`;端口未监听时会启动 Dashboard。首次发现 `dashboard.basic_auth` 未配置时,它会写入下列默认认证并重启 Dashboard 载入配置:
+
+| 项目 | 默认值 |
+| --- | --- |
+| 用户名 | `admin` |
+| 密码 | `admin` |
+| 密码存储 | `password_hash` scrypt 哈希,不保存明文 |
+| 会话签名密钥 | 首次配置时随机生成 |
+
+`admin/admin` 是发布要求的已知默认凭据,不适合长期使用。首次登录后,管理员必须用 Hermes 配置替换该账号或密码,并重启 8800 服务,让监督器加载新的认证配置。监督器只会填补缺失的用户名、密码和会话签名密钥,绝不会覆盖已有的用户名、密码哈希、密码或密钥。
+
+如需在首次登录后替换默认密码,可用下列命令以 scrypt 哈希方式写入配置。不要将新密码放入命令历史、Git 或日志。
+
+```bash
+HERMES_AGENT_HOME="${HERMES_AGENT_HOME:-$HOME/.hermes/hermes-agent}"
+read -r -p 'Dashboard username: ' DASHBOARD_USERNAME
+read -r -s -p 'Dashboard password: ' DASHBOARD_PASSWORD
+printf '\n'
+
+DASHBOARD_PASSWORD_HASH="$(
+  DASHBOARD_PASSWORD="$DASHBOARD_PASSWORD" \
+    "$HERMES_AGENT_HOME/venv/bin/python" -c \
+    'import os; from plugins.dashboard_auth.basic import hash_password; print(hash_password(os.environ["DASHBOARD_PASSWORD"]))'
+)"
+DASHBOARD_SECRET="$("$HERMES_AGENT_HOME/venv/bin/python" -c 'import secrets; print(secrets.token_urlsafe(32))')"
+
+hermes config set dashboard.basic_auth.username "$DASHBOARD_USERNAME"
+hermes config set dashboard.basic_auth.password_hash "$DASHBOARD_PASSWORD_HASH"
+hermes config set dashboard.basic_auth.secret "$DASHBOARD_SECRET"
+
+unset DASHBOARD_PASSWORD DASHBOARD_PASSWORD_HASH DASHBOARD_SECRET
+```
+
+`password_hash` 优先于明文 `password`。`secret` 使认证会话可跨 Dashboard 重启继续有效。若 Dashboard 由 LaunchAgent 或其他监督器运行,配置变更后必须通过**同一个监督器**重启它,确保新认证提供方被加载。
+
+受管服务负责启动和重启 9119,不能再同时安装独立的 Dashboard LaunchAgent 或手动长期运行 `hermes dashboard`。认证加载可在本机无密码回显地检查:
+
+```bash
+hermes config get dashboard.basic_auth.username
+curl --fail --silent --show-error http://127.0.0.1:9119/api/auth/providers
+```
+
+第二条命令应能返回包含用户名密码登录提供方的 JSON。不要在自动化日志、命令行、环境变量转储或 Git 配置中记录替换后的明文密码。
+
+## 1. 获取并校验指定发布版本
+
+```bash
+RELEASE_VERSION=v0.1.0
+git fetch --tags
+git checkout "$RELEASE_VERSION"
+
+test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_VERSION"
+test "$(node -p \"require('./package.json').version\")" = "0.1.0"
+test "$(node -e \"console.log(require('./hermes-plugins/yaoyao/dashboard/manifest.json').version)\")" = "1.6.1"
+```
+
+这会进入 detached HEAD 状态,属于部署发布版本的预期行为。若 Agent 负责修改代码,应另行创建工作分支,不要在此发布检出上修改。
+
+## 2. 安装或升级 Hermes Dashboard 插件
+
+前提:目标机器已安装 Hermes,且 `hermes dashboard --status` 可执行。插件必须完整放在 `<Hermes 数据目录>/plugins/yaoyao/dashboard/`,不能只复制 Python 文件或前端 `dist/` 目录。
+
+```bash
+HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
+PLUGIN_DIR="$HERMES_HOME/plugins/yaoyao/dashboard"
+BACKUP_DIR="${PLUGIN_DIR}.backup-$(date +%Y%m%d%H%M%S)"
+
+if [ -d "$PLUGIN_DIR" ]; then
+  cp -a "$PLUGIN_DIR" "$BACKUP_DIR"
+fi
+
+mkdir -p "$PLUGIN_DIR"
+rsync -a --delete \
+  --exclude '__pycache__/' \
+  --exclude '*.py[cod]' \
+  --exclude '.pytest_cache/' \
+  hermes-plugins/yaoyao/dashboard/ "$PLUGIN_DIR/"
+
+test -f "$PLUGIN_DIR/manifest.json"
+test -f "$PLUGIN_DIR/plugin_api.py"
+test -f "$PLUGIN_DIR/group_plugin_api.py"
+test -f "$PLUGIN_DIR/dist/index.js"
+test "$(node -e \"console.log(require(process.argv[1]).version)\" "$PLUGIN_DIR/manifest.json")" = "1.6.1"
+```
+
+`rsync --delete` 的范围严格限于 `$PLUGIN_DIR`,不会删除其他 Hermes 插件或用户数据。若目标目录已有额外的本机定制文件,应先将其移出目录或改用不带 `--delete` 的同步命令。
+
+不要提交、复制或恢复 `data/`、`__pycache__/`、`.pytest_cache/` 等运行时内容。归档数据库和本机状态不属于插件发布物。
+
+## 3. 由 8800 服务担任唯一 Dashboard 监督者
+
+插件文件只有在 Dashboard 重载后才会生效。安装本项目的受管服务后,8800 服务是 Dashboard 的唯一持续监督者;不要保留其他 Dashboard LaunchAgent 或手动常驻进程。
+
+| Dashboard 情况 | 正确做法 | 禁止做法 |
+| --- | --- | --- |
+| 需要让新插件生效 | 执行 `hermes dashboard --stop`,等待 8800 监督器在 5 秒内自动启动 | 另起常驻 `hermes dashboard` 进程 |
+| 9119 未监听 | 等待 8800 监督器自动启动;检查其日志 | 手动重复启动多个 Dashboard |
+
+## 4. 验证插件和 Dashboard
+
+```bash
+hermes dashboard --status
+lsof -nP -iTCP:9119 -sTCP:LISTEN
+```
+
+验收条件:Dashboard 状态正常,且只有一个 `9119` TCP 监听器。插件路由位于 `/api/plugins/yaoyao/`,该 API 需要现有 Hermes 登录会话;不要为了探测路由而关闭认证。除非用户明确授权且已配置对应生命周期所有者,否则保持 `9119` 仅本机监听。
+
+## 5. 可选:部署夭夭 Web
+
+夭夭 Web 默认监听 `8800` 并连接 Dashboard `9119`。通过 LaunchAgent 安装后,它持续监督 Dashboard `9119`,但在自身停止或卸载时不会主动停止正在运行的 Dashboard。
+
+```bash
+npm ci
+npm run build
+node bin/hermes-yaoyao.mjs service install
+node bin/hermes-yaoyao.mjs service status
+```
+
+若需要显式指定 Dashboard 上游,只在安装服务前设置 `HERMES_YAOYAO_UPSTREAM`。默认值为 `http://127.0.0.1:9119`。只有用户明确要求时才允许局域网:设置 `HERMES_YAOYAO_HOST=0.0.0.0` 和 `HERMES_YAOYAO_ALLOW_INSECURE_LAN=1`;受管服务会同时将 9119 绑定至 `0.0.0.0`。
+
+## 6. 失败处理
+
+1. 记录失败的命令、退出码、Dashboard 状态和 `9119` 监听信息。
+2. 若插件重载失败,使用第 2 步生成的 `$BACKUP_DIR` 恢复到 `$PLUGIN_DIR`,执行 `hermes dashboard --stop`,再由 8800 监督器重启 Dashboard。
+3. 不要通过多次执行 `hermes dashboard --no-open`、重复安装 LaunchAgent 或停止无关 Gateway 来“尝试修复”。这些操作会掩盖真正错误,或产生重复监听进程。
+
+版本不匹配、没有可用备份或无法确认 Dashboard 所有者时,停止自动化流程并报告,而不是猜测或覆盖现有安装。

+ 1 - 0
scripts/verify-lifecycle.mjs

@@ -36,6 +36,7 @@ const child = spawn(process.execPath, [entry.pathname], {
     HERMES_YAOYAO_HOST: '127.0.0.1',
     HERMES_YAOYAO_PORT: String(port),
     HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119',
+    HERMES_YAOYAO_SUPERVISE_DASHBOARD: '0',
   },
   stdio: ['ignore', 'pipe', 'pipe'],
 })

+ 4 - 1
src/server/config.ts

@@ -17,6 +17,7 @@ export interface ServerConfig {
   allowInsecureLan: boolean
   insecureLan: boolean
   production: boolean
+  superviseDashboard?: boolean
 }
 
 function flag(value: string | undefined): boolean {
@@ -83,7 +84,7 @@ export function isLoopbackUpstream(upstream: URL): boolean {
 export function loadServerConfig(
   env: NodeJS.ProcessEnv = process.env,
 ): ServerConfig {
-  const host = env.HERMES_YAOYAO_HOST?.trim() || '0.0.0.0'
+  const host = env.HERMES_YAOYAO_HOST?.trim() || '127.0.0.1'
   const port = parsePort(env.HERMES_YAOYAO_PORT)
   const upstream = parseUpstream(env.HERMES_YAOYAO_UPSTREAM)
   const home = resolve(env.HERMES_YAOYAO_HOME?.trim() || `${homedir()}/.hermes-yaoyao`)
@@ -97,6 +98,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 insecureLan = !tlsCert && !isLoopbackHost(host)
   if (production && insecureLan && !allowInsecureLan) {
     throw new Error(
@@ -124,5 +126,6 @@ export function loadServerConfig(
     allowInsecureLan,
     insecureLan,
     production,
+    superviseDashboard,
   }
 }

+ 143 - 0
src/server/dashboardSupervisor.ts

@@ -0,0 +1,143 @@
+import { execFileSync, spawn } from 'node:child_process'
+import { randomBytes, scryptSync } from 'node:crypto'
+import { createConnection } from 'node:net'
+
+export const DEFAULT_DASHBOARD_USERNAME = 'admin'
+export const DEFAULT_DASHBOARD_PASSWORD = 'admin'
+const DASHBOARD_PORT = 9119
+const SUPERVISION_INTERVAL_MS = 5_000
+
+type Run = (args: readonly string[]) => string
+type Launch = (args: readonly string[]) => void
+type Probe = (host: string, port: number) => Promise<boolean>
+
+export interface DashboardSupervisorOptions {
+  allowLan: boolean
+  command?: string
+  intervalMs?: number
+  run?: Run
+  launch?: Launch
+  probe?: Probe
+  log?: (message: string) => void
+}
+
+function defaultRun(command: string): Run {
+  return (args) => execFileSync(command, args, { encoding: 'utf8', stdio: 'pipe' })
+}
+
+function defaultLaunch(command: string): Launch {
+  return (args) => {
+    const child = spawn(command, [...args], { detached: true, stdio: 'ignore' })
+    child.unref()
+  }
+}
+
+function portIsListening(host: string, port: number): Promise<boolean> {
+  return new Promise((resolve) => {
+    const socket = createConnection({ host, port })
+    const finish = (value: boolean) => {
+      socket.removeAllListeners()
+      socket.destroy()
+      resolve(value)
+    }
+    socket.setTimeout(1_000)
+    socket.once('connect', () => finish(true))
+    socket.once('timeout', () => finish(false))
+    socket.once('error', () => finish(false))
+  })
+}
+
+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 })
+  return `scrypt$16384$8$1$${salt.toString('base64')}$${derived.toString('base64')}`
+}
+
+/** Keeps the local Hermes Dashboard available for the managed 8800 service. */
+export class DashboardSupervisor {
+  private readonly command: string
+  private readonly dashboardHost: string
+  private readonly run: Run
+  private readonly launch: Launch
+  private readonly probe: Probe
+  private readonly log: (message: string) => void
+  private readonly intervalMs: number
+  private timer: NodeJS.Timeout | undefined
+  private checking = false
+
+  constructor(options: DashboardSupervisorOptions) {
+    this.command = options.command ?? 'hermes'
+    this.dashboardHost = options.allowLan ? '0.0.0.0' : '127.0.0.1'
+    this.run = options.run ?? defaultRun(this.command)
+    this.launch = options.launch ?? defaultLaunch(this.command)
+    this.probe = options.probe ?? portIsListening
+    this.log = options.log ?? console.info
+    this.intervalMs = options.intervalMs ?? SUPERVISION_INTERVAL_MS
+  }
+
+  start(): void {
+    void this.checkNow()
+    this.timer = setInterval(() => { void this.checkNow() }, this.intervalMs)
+    this.timer.unref()
+  }
+
+  stop(): void {
+    if (this.timer) clearInterval(this.timer)
+    this.timer = undefined
+  }
+
+  async checkNow(): Promise<void> {
+    if (this.checking) return
+    this.checking = true
+    try {
+      const credentialsChanged = this.ensureBasicAuthentication()
+      let running = await this.probe('127.0.0.1', DASHBOARD_PORT)
+      if (credentialsChanged && running) {
+        this.log('Hermes Dashboard authentication was configured; restarting 9119 to load it.')
+        this.run(['dashboard', '--stop'])
+        running = await this.probe('127.0.0.1', DASHBOARD_PORT)
+      }
+      if (running) return
+      this.log(`Hermes Dashboard is unavailable on 9119; starting it on ${this.dashboardHost}.`)
+      this.launch(['dashboard', '--host', this.dashboardHost, '--no-open'])
+    } catch (error) {
+      this.log(`Hermes Dashboard supervision failed: ${error instanceof Error ? error.message : String(error)}`)
+    } finally {
+      this.checking = false
+    }
+  }
+
+  private value(key: string): string {
+    try {
+      return this.run(['config', 'get', key]).trim()
+    } catch {
+      return ''
+    }
+  }
+
+  private set(key: string, value: string): void {
+    this.run(['config', 'set', key, value])
+  }
+
+  private ensureBasicAuthentication(): boolean {
+    const username = this.value('dashboard.basic_auth.username')
+    const configuredPasswordHash = this.value('dashboard.basic_auth.password_hash')
+    const password = this.value('dashboard.basic_auth.password')
+    const secret = this.value('dashboard.basic_auth.secret')
+    let changed = false
+
+    if (!username) {
+      this.set('dashboard.basic_auth.username', DEFAULT_DASHBOARD_USERNAME)
+      changed = true
+    }
+    if (!configuredPasswordHash && !password) {
+      this.set('dashboard.basic_auth.password_hash', passwordHash(DEFAULT_DASHBOARD_PASSWORD))
+      changed = true
+    }
+    if (!secret) {
+      this.set('dashboard.basic_auth.secret', randomBytes(32).toString('base64url'))
+      changed = true
+    }
+    return changed
+  }
+}

+ 7 - 0
src/server/index.ts

@@ -3,9 +3,14 @@ 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 { DashboardSupervisor } from './dashboardSupervisor.js'
 
 const runtime = createApplication()
 const nodeRuntime = createNodeServer(runtime)
+const dashboardSupervisor = runtime.config.superviseDashboard
+  ? new DashboardSupervisor({ allowLan: !isLoopbackHost(runtime.config.host) })
+  : undefined
 let closeFrontend = async (): Promise<void> => undefined
 
 if (runtime.config.production) {
@@ -65,6 +70,7 @@ nodeRuntime.server.listen(runtime.config.port, runtime.config.host, () => {
   if (runtime.config.insecureLan) {
     console.warn('Warning: trusted-LAN HTTP mode is enabled; credentials are not encrypted in transit.')
   }
+  dashboardSupervisor?.start()
 })
 
 let closing = false
@@ -72,6 +78,7 @@ async function shutdown(): Promise<void> {
   if (closing) return
   closing = true
   try {
+    dashboardSupervisor?.stop()
     await closeFrontend()
     await nodeRuntime.close()
   } finally {

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

@@ -0,0 +1,71 @@
+import { describe, expect, it } from 'vitest'
+import {
+  DashboardSupervisor,
+  DEFAULT_DASHBOARD_USERNAME,
+} from '../../src/server/dashboardSupervisor.js'
+
+function supervisor(overrides: {
+  values?: Record<string, string>
+  running?: boolean
+  allowLan?: boolean
+} = {}) {
+  const values = { ...overrides.values }
+  let running = overrides.running ?? false
+  const calls: string[][] = []
+  const launches: string[][] = []
+  const instance = new DashboardSupervisor({
+    allowLan: overrides.allowLan ?? false,
+    run(args) {
+      calls.push([...args])
+      if (args[0] === 'config' && args[1] === 'get') return values[args[2]] ?? ''
+      if (args[0] === 'config' && args[1] === 'set') {
+        values[args[2]] = args[3]
+        return ''
+      }
+      if (args[0] === 'dashboard' && args[1] === '--stop') {
+        running = false
+        return ''
+      }
+      return ''
+    },
+    launch(args) { launches.push([...args]) },
+    probe: async () => running,
+    log: () => undefined,
+  })
+  return { instance, calls, launches, values }
+}
+
+describe('DashboardSupervisor', () => {
+  it('configures the requested default credentials and starts an unavailable dashboard', async () => {
+    const value = supervisor()
+    await value.instance.checkNow()
+
+    expect(value.values['dashboard.basic_auth.username']).toBe(DEFAULT_DASHBOARD_USERNAME)
+    expect(value.values['dashboard.basic_auth.password_hash']).toMatch(/^scrypt\$16384\$8\$1\$/)
+    expect(value.values['dashboard.basic_auth.secret']).toMatch(/.{32,}/)
+    expect(value.launches).toEqual([['dashboard', '--host', '127.0.0.1', '--no-open']])
+  })
+
+  it('does not overwrite configured dashboard credentials', 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.checkNow()
+
+    expect(value.calls.filter((args) => args[1] === 'set')).toEqual([])
+    expect(value.launches).toEqual([])
+  })
+
+  it('restarts a running dashboard after filling missing authentication and uses LAN binding when enabled', async () => {
+    const value = supervisor({ running: true, allowLan: true })
+    await value.instance.checkNow()
+
+    expect(value.calls).toContainEqual(['dashboard', '--stop'])
+    expect(value.launches).toEqual([['dashboard', '--host', '0.0.0.0', '--no-open']])
+  })
+})

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

@@ -102,4 +102,10 @@ describe('server security boundary', () => {
       HERMES_YAOYAO_ALLOW_INSECURE_LAN: '1',
     }).insecureLan).toBe(true)
   })
+
+  it('defaults the YaoYao Web listener to loopback', () => {
+    const value = loadServerConfig({})
+    expect(value.host).toBe('127.0.0.1')
+    expect(value.insecureLan).toBe(false)
+  })
 })