基于官方 nousresearch/hermes-agent 镜像的 CLI、Docker Compose 与 Web 管理界面,支持多实例部署;敏感配置与实例数据由 .gitignore 排除。 Co-authored-by: Cursor <cursoragent@cursor.com>
928 lines
31 KiB
Python
928 lines
31 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Hermes Agent Web 管理端:单进程 Flask(模板渲染)。对实例的启停、状态、compose 校验等均只调用
|
||
hermes-agent-home/hermes-agent-cli.sh;config.yaml 编辑为直接写宿主机 data 卷;destroy 会删除该实例整目录。
|
||
|
||
实例目录下可有 ``web-meta.json``(与 ``data/`` 同级),由本管理台维护,用于所属人等标注,
|
||
不写入 ``config.yaml``,避免与网关配置混杂。
|
||
|
||
环境变量(可选,见文件顶注释):
|
||
HERMES_AGENT_WEB_SECRET_KEY Flask session 密钥
|
||
HERMES_AGENT_WEB_ADMIN_USER 登录用户名(默认 hermes)
|
||
HERMES_AGENT_WEB_ADMIN_PASSWORD 登录密码(必填)
|
||
HERMES_AGENT_WEB_HOST / HERMES_AGENT_WEB_PORT 监听地址(默认 127.0.0.1:8781)
|
||
HERMES_AGENT_WEB_LOG_CLI 是否打印执行的 hermes-agent-cli.sh 与输出(默认 1;0/false 关闭)
|
||
|
||
启动参数(与 df-report ``run.py`` 类似,便于本地一行起服务):
|
||
python app.py --admin-password '你的密码'
|
||
python app.py --host 0.0.0.0 --port 8781 --admin-password secret
|
||
|
||
依赖补充:交互式频道终端需要 flask-sock、pexpect;仅 macOS/Linux。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import shlex
|
||
import shutil
|
||
import subprocess
|
||
import secrets
|
||
import sys
|
||
import threading
|
||
import time
|
||
from datetime import timedelta
|
||
from pathlib import Path
|
||
|
||
from dotenv import load_dotenv
|
||
from flask import (
|
||
Flask,
|
||
abort,
|
||
flash,
|
||
redirect,
|
||
render_template,
|
||
request,
|
||
session,
|
||
url_for,
|
||
)
|
||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||
from werkzeug.security import check_password_hash, generate_password_hash
|
||
|
||
try:
|
||
import pexpect
|
||
except ImportError:
|
||
pexpect = None # type: ignore
|
||
|
||
from flask_sock import Sock
|
||
|
||
# hermes-agent-web/ 的父目录应为 hermes-agent-home
|
||
HERMES_AGENT_HOME = Path(__file__).resolve().parent.parent
|
||
ENV_FILE = HERMES_AGENT_HOME / ".env"
|
||
load_dotenv(ENV_FILE)
|
||
|
||
DEFAULT_ADMIN_USER = os.environ.get("HERMES_AGENT_WEB_ADMIN_USER", "hermes")
|
||
# 显式 pbkdf2:部分环境 Python 未编译 hashlib.scrypt,Werkzeug 默认 scrypt 会 AttributeError
|
||
_PASSWORD_HASH_METHOD = "pbkdf2:sha256"
|
||
_raw_pw = ""
|
||
ADMIN_PASSWORD_HASH = ""
|
||
|
||
|
||
def _reload_admin_password_config() -> None:
|
||
"""从环境变量 HERMES_AGENT_WEB_ADMIN_PASSWORD 刷新哈希(支持启动参数先写入 env 再调用)。"""
|
||
global _raw_pw, ADMIN_PASSWORD_HASH
|
||
_raw_pw = os.environ.get("HERMES_AGENT_WEB_ADMIN_PASSWORD", "")
|
||
if _raw_pw.startswith("pbkdf2:") or _raw_pw.startswith("scrypt:"):
|
||
ADMIN_PASSWORD_HASH = _raw_pw
|
||
else:
|
||
ADMIN_PASSWORD_HASH = (
|
||
generate_password_hash(_raw_pw, method=_PASSWORD_HASH_METHOD)
|
||
if _raw_pw
|
||
else ""
|
||
)
|
||
|
||
|
||
_reload_admin_password_config()
|
||
|
||
SECRET_KEY = os.environ.get(
|
||
"HERMES_AGENT_WEB_SECRET_KEY",
|
||
secrets.token_hex(32),
|
||
)
|
||
|
||
|
||
def _pty_ws_serializer() -> URLSafeTimedSerializer:
|
||
return URLSafeTimedSerializer(SECRET_KEY, salt="hermes-agent-web-pty-v1")
|
||
|
||
|
||
def make_pty_token(iid: int, purpose: str) -> str:
|
||
return _pty_ws_serializer().dumps({"iid": int(iid), "purpose": str(purpose)})
|
||
|
||
|
||
def verify_pty_token(token: str | None, iid: int, purpose: str) -> bool:
|
||
if not token:
|
||
return False
|
||
try:
|
||
data = _pty_ws_serializer().loads(token, max_age=900)
|
||
return int(data["iid"]) == int(iid) and str(data["purpose"]) == str(purpose)
|
||
except (BadSignature, SignatureExpired, TypeError, KeyError, ValueError):
|
||
return False
|
||
|
||
|
||
def make_guide_pty_token(iid: int) -> str:
|
||
return make_pty_token(iid, "guide")
|
||
|
||
|
||
def make_docker_pty_token(iid: int) -> str:
|
||
return make_pty_token(iid, "docker")
|
||
|
||
|
||
def verify_guide_pty_token(token: str | None, iid: int) -> bool:
|
||
return verify_pty_token(token, iid, "guide")
|
||
|
||
|
||
def verify_docker_pty_token(token: str | None, iid: int) -> bool:
|
||
return verify_pty_token(token, iid, "docker")
|
||
|
||
DATA_ROOT = Path(
|
||
os.environ.get("HERMES_HOME_DATA_DIR", "")
|
||
or (HERMES_AGENT_HOME / "hermes-agent-instances")
|
||
).resolve()
|
||
CLI_SCRIPT = HERMES_AGENT_HOME / "hermes-agent-cli.sh"
|
||
# 与 hermes-agent-cli.sh 中标记文件名一致
|
||
MARK_SETUP_DONE = ".hermes-setup-completed"
|
||
MARK_SETUP_DECLINED = ".hermes-setup-declined"
|
||
WEB_META_FILENAME = "web-meta.json"
|
||
_OWNER_MAX_LEN = 128
|
||
|
||
_LOG = logging.getLogger(__name__)
|
||
_CLI_LOG_ENABLED = os.environ.get("HERMES_AGENT_WEB_LOG_CLI", "1").lower() not in (
|
||
"0",
|
||
"false",
|
||
"no",
|
||
"off",
|
||
)
|
||
_CLI_OUT_MAX = 64_000
|
||
_CLI_TIMEOUT_DEFAULT = int(os.environ.get("HERMES_AGENT_WEB_CLI_TIMEOUT", "300"))
|
||
_CLI_TIMEOUT_LONG = int(os.environ.get("HERMES_AGENT_WEB_CLI_TIMEOUT_LONG", "3600"))
|
||
|
||
|
||
def _truncate_out(s: str) -> str:
|
||
if len(s) <= _CLI_OUT_MAX:
|
||
return s
|
||
return s[:_CLI_OUT_MAX] + f"\n... [stdout/stderr 已截断,原长度 {len(s)} 字符]"
|
||
|
||
|
||
def _data_root() -> Path:
|
||
DATA_ROOT.mkdir(parents=True, exist_ok=True)
|
||
return DATA_ROOT
|
||
|
||
|
||
def _run_cli(
|
||
*cli_args: str, timeout: int | None = None
|
||
) -> subprocess.CompletedProcess[str]:
|
||
"""所有实例生命周期与 Compose 操作均通过 hermes-agent-home/hermes-agent-cli.sh。"""
|
||
if not CLI_SCRIPT.is_file():
|
||
raise RuntimeError(f"找不到 CLI: {CLI_SCRIPT}")
|
||
if timeout is None:
|
||
timeout = _CLI_TIMEOUT_DEFAULT
|
||
env = os.environ.copy()
|
||
if os.environ.get("HERMES_HOME_DATA_DIR"):
|
||
env["HERMES_HOME_DATA_DIR"] = os.environ["HERMES_HOME_DATA_DIR"]
|
||
cmd = ["/usr/bin/env", "bash", str(CLI_SCRIPT), *cli_args]
|
||
try:
|
||
cp = subprocess.run(
|
||
cmd,
|
||
cwd=str(HERMES_AGENT_HOME),
|
||
capture_output=True,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
timeout=timeout,
|
||
env=env,
|
||
)
|
||
except subprocess.TimeoutExpired as e:
|
||
out = (e.stdout or "") if isinstance(e.stdout, str) else (e.stdout or b"").decode("utf-8", errors="replace")
|
||
err = (e.stderr or "") if isinstance(e.stderr, str) else (e.stderr or b"").decode("utf-8", errors="replace")
|
||
msg = f"命令执行超过 {timeout} 秒已中止。"
|
||
if "compose-build" in cli_args or "run" in cli_args:
|
||
msg += " 首次构建 Hermes 镜像较久,请用终端执行 ./hermes-agent-cli.sh compose-build 1,或 Web「构建镜像」。"
|
||
_LOG.error("hermes-agent-cli 超时: %s", " ".join(shlex.quote(x) for x in cmd))
|
||
cp = subprocess.CompletedProcess(cmd, 124, out, err + "\n" + msg)
|
||
if _CLI_LOG_ENABLED:
|
||
line = " ".join(shlex.quote(x) for x in cmd)
|
||
_LOG.info("hermes-agent-cli: %s", line)
|
||
_LOG.info("hermes-agent-cli exit=%s", cp.returncode)
|
||
out = cp.stdout or ""
|
||
err = cp.stderr or ""
|
||
if out.strip():
|
||
_LOG.info("hermes-agent-cli stdout:\n%s", _truncate_out(out))
|
||
if err.strip():
|
||
_LOG.info("hermes-agent-cli stderr:\n%s", _truncate_out(err))
|
||
if not out.strip() and not err.strip():
|
||
_LOG.info("hermes-agent-cli (无 stdout/stderr)")
|
||
return cp
|
||
|
||
|
||
def _cli_compose_check() -> str | None:
|
||
"""通过脚本的 check 子命令校验 Docker、HERMES_SOURCE 与 compose(含 docker compose config)。"""
|
||
cp = _run_cli("check", timeout=60)
|
||
if cp.returncode != 0:
|
||
return (cp.stderr or cp.stdout or "check 失败").strip()
|
||
return None
|
||
|
||
|
||
def _list_instance_ids_from_dirs_only() -> list[int]:
|
||
root = _data_root()
|
||
ids: list[int] = []
|
||
for p in root.iterdir():
|
||
if p.is_dir() and re.fullmatch(r"[0-9]+", p.name):
|
||
ids.append(int(p.name))
|
||
return sorted(ids)
|
||
|
||
|
||
def list_instance_ids() -> list[int]:
|
||
"""数据目录序号 ∪ hermes-agent-cli.sh list-ids(Docker 中 hermes-<N>,含已停止容器)。"""
|
||
cp = _run_cli("list-ids", timeout=60)
|
||
if cp.returncode != 0:
|
||
return _list_instance_ids_from_dirs_only()
|
||
seen: set[int] = set()
|
||
for line in (cp.stdout or "").splitlines():
|
||
s = line.strip()
|
||
if s.isdigit():
|
||
seen.add(int(s))
|
||
return sorted(seen)
|
||
|
||
|
||
def next_instance_id() -> int:
|
||
ids = list_instance_ids()
|
||
return max(ids) + 1 if ids else 1
|
||
|
||
|
||
def instance_status(instance_id: int) -> dict:
|
||
"""gateway 容器状态摘要(经 hermes-agent-cli.sh status)。"""
|
||
try:
|
||
cp = _run_cli("status", str(instance_id), timeout=120)
|
||
except Exception as e:
|
||
return {"running": False, "state": "error", "detail": str(e)}
|
||
if cp.returncode != 0:
|
||
return {
|
||
"running": False,
|
||
"state": "compose_error",
|
||
"detail": (cp.stderr or cp.stdout or "").strip(),
|
||
}
|
||
raw = (cp.stdout or "").strip()
|
||
if not raw:
|
||
return {"running": False, "state": "not_created", "detail": ""}
|
||
row = None
|
||
for line in raw.splitlines():
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
obj = json.loads(line)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
if isinstance(obj, list):
|
||
row = obj[0] if obj else None
|
||
else:
|
||
row = obj
|
||
break
|
||
if row is None:
|
||
try:
|
||
obj = json.loads(raw)
|
||
if isinstance(obj, list) and obj:
|
||
row = obj[0]
|
||
elif isinstance(obj, dict):
|
||
row = obj
|
||
except json.JSONDecodeError:
|
||
pass
|
||
if row is None:
|
||
return {"running": False, "state": "unknown", "detail": raw[:500]}
|
||
st = (row.get("State") or row.get("state") or "").lower()
|
||
running = st == "running"
|
||
status_line = (row.get("Status") or row.get("status") or "").strip()
|
||
exit_code = row.get("ExitCode")
|
||
health = (row.get("Health") or row.get("health") or "").strip()
|
||
detail_parts: list[str] = []
|
||
if status_line:
|
||
detail_parts.append(status_line)
|
||
if health and health not in status_line.lower():
|
||
detail_parts.append(f"Health: {health}")
|
||
if exit_code is not None and str(exit_code) not in ("0", "") and not running:
|
||
detail_parts.append(f"ExitCode: {exit_code}")
|
||
return {
|
||
"running": running,
|
||
"state": st or "unknown",
|
||
"name": (row.get("Name") or row.get("Names") or "").strip(),
|
||
"status_text": status_line,
|
||
"exit_code": exit_code,
|
||
"health": health,
|
||
"detail": " · ".join(detail_parts) if detail_parts else "",
|
||
}
|
||
|
||
|
||
def instance_ports(instance_id: int) -> tuple[int, int]:
|
||
"""网关 API 端口、Dashboard 端口(与 hermes-agent-cli.sh 一致)。"""
|
||
return 8641 + instance_id, 9118 + instance_id
|
||
|
||
|
||
def instance_data_dir(instance_id: int) -> Path:
|
||
return _data_root() / str(instance_id) / "data"
|
||
|
||
|
||
def config_path(instance_id: int) -> Path:
|
||
return instance_data_dir(instance_id) / "config.yaml"
|
||
|
||
|
||
def instance_has_config(instance_id: int) -> bool:
|
||
data = instance_data_dir(instance_id)
|
||
return (data / "config.yaml").is_file() or (data / ".env").is_file()
|
||
|
||
|
||
def _instance_web_meta_path(instance_id: int) -> Path:
|
||
return _data_root() / str(instance_id) / WEB_META_FILENAME
|
||
|
||
|
||
def _read_instance_web_meta(instance_id: int) -> dict:
|
||
path = _instance_web_meta_path(instance_id)
|
||
if not path.is_file():
|
||
return {}
|
||
try:
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
return data if isinstance(data, dict) else {}
|
||
except (json.JSONDecodeError, OSError):
|
||
return {}
|
||
|
||
|
||
def get_instance_owner(instance_id: int) -> str:
|
||
raw = _read_instance_web_meta(instance_id).get("owner")
|
||
if raw is None:
|
||
return ""
|
||
return str(raw).strip()
|
||
|
||
|
||
def set_instance_owner(instance_id: int, owner: str) -> None:
|
||
owner = (owner or "").strip()[:_OWNER_MAX_LEN]
|
||
path = _instance_web_meta_path(instance_id)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
meta = _read_instance_web_meta(instance_id)
|
||
if not owner:
|
||
meta.pop("owner", None)
|
||
if not meta:
|
||
if path.is_file():
|
||
path.unlink()
|
||
return
|
||
else:
|
||
meta["owner"] = owner
|
||
path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
|
||
|
||
def ensure_instance_dirs(instance_id: int) -> Path:
|
||
data = instance_data_dir(instance_id)
|
||
data.mkdir(parents=True, exist_ok=True)
|
||
return data
|
||
|
||
|
||
def _instance_data_dir(instance_id: int) -> Path:
|
||
return instance_data_dir(instance_id)
|
||
|
||
|
||
def _guide_include_setup_wizard(instance_id: int) -> bool:
|
||
"""是否在本页自动跑 hermes setup(与脚本标记一致)。"""
|
||
data = _instance_data_dir(instance_id)
|
||
if (data / MARK_SETUP_DONE).is_file():
|
||
return False
|
||
if (data / MARK_SETUP_DECLINED).is_file():
|
||
return False
|
||
return True
|
||
|
||
|
||
def _guide_shell_command(instance_id: int) -> tuple[str, str]:
|
||
"""返回 bash -c 的一条命令串,以及发给浏览器的说明文案。"""
|
||
q_home = shlex.quote(str(HERMES_AGENT_HOME))
|
||
nid = int(instance_id)
|
||
if _guide_include_setup_wizard(instance_id):
|
||
inner = (
|
||
f"set -euo pipefail; cd {q_home} && "
|
||
f"./hermes-agent-cli.sh setup {nid} && "
|
||
f"./hermes-agent-cli.sh run --quick {nid} && "
|
||
f"exec ./hermes-agent-cli.sh exec {nid}"
|
||
)
|
||
hint = (
|
||
"正在自动执行:hermes setup 向导 → 启动 gateway → 进入容器 shell。"
|
||
"按向导提示填写 API 密钥与频道配置。"
|
||
)
|
||
else:
|
||
inner = (
|
||
f"set -euo pipefail; cd {q_home} && "
|
||
f"./hermes-agent-cli.sh run --quick {nid} && "
|
||
f"exec ./hermes-agent-cli.sh exec {nid}"
|
||
)
|
||
hint = (
|
||
"将启动 gateway 并进入容器 shell(工作目录 /opt/data)。"
|
||
"可运行 hermes 子命令或 exit 退出。"
|
||
)
|
||
return inner, hint
|
||
|
||
|
||
def _docker_shell_command(instance_id: int) -> tuple[str, str]:
|
||
"""返回进入实例 gateway 容器 shell 的宿主机命令,以及浏览器提示。"""
|
||
q_home = shlex.quote(str(HERMES_AGENT_HOME))
|
||
nid = int(instance_id)
|
||
inner = (
|
||
f"set -euo pipefail; cd {q_home} && exec ./hermes-agent-cli.sh exec {nid}"
|
||
)
|
||
hint = (
|
||
"将连接到当前实例运行中的 Hermes gateway 容器(/opt/data)。"
|
||
"若未启动,请先回列表页点「启动」。"
|
||
)
|
||
return inner, hint
|
||
|
||
|
||
app = Flask(__name__)
|
||
app.secret_key = SECRET_KEY
|
||
app.permanent_session_lifetime = timedelta(days=7)
|
||
sock = Sock(app)
|
||
|
||
|
||
def login_required(view):
|
||
from functools import wraps
|
||
|
||
@wraps(view)
|
||
def wrapped(*args, **kwargs):
|
||
if not session.get("logged_in"):
|
||
return redirect(url_for("login", next=request.path))
|
||
return view(*args, **kwargs)
|
||
|
||
return wrapped
|
||
|
||
|
||
@app.context_processor
|
||
def inject_csrf():
|
||
tok = session.get("csrf_token")
|
||
if not tok:
|
||
tok = secrets.token_hex(16)
|
||
session["csrf_token"] = tok
|
||
return dict(csrf_token=tok)
|
||
|
||
|
||
def _check_csrf():
|
||
if request.form.get("csrf_token") != session.get("csrf_token"):
|
||
abort(403)
|
||
|
||
|
||
def _safe_login_next(url: str) -> str:
|
||
"""登录后仅跳转到可 GET 的页面,避免重放 POST 路由导致 405。"""
|
||
u = (url or "").strip()
|
||
if not u.startswith("/") or u.startswith("//"):
|
||
return url_for("index")
|
||
# POST-only 或易误用的路径
|
||
blocked = ("/instance/create",)
|
||
if u in blocked or "/run" in u or "/stop" in u or "/destroy" in u:
|
||
return url_for("index")
|
||
return u
|
||
|
||
|
||
@app.route("/login", methods=["GET", "POST"])
|
||
def login():
|
||
if not ADMIN_PASSWORD_HASH and _raw_pw == "":
|
||
flash(
|
||
"未设置 HERMES_AGENT_WEB_ADMIN_PASSWORD,请在环境变量、部署配置中设置,"
|
||
"或使用 python app.py --admin-password 指定后再登录。",
|
||
"error",
|
||
)
|
||
if request.method == "POST":
|
||
_check_csrf()
|
||
user = request.form.get("username", "").strip()
|
||
pw = request.form.get("password", "")
|
||
next_url = _safe_login_next(request.form.get("next") or "")
|
||
if not ADMIN_PASSWORD_HASH:
|
||
flash("管理员密码未配置。", "error")
|
||
elif user == DEFAULT_ADMIN_USER and check_password_hash(ADMIN_PASSWORD_HASH, pw):
|
||
session.clear()
|
||
session.permanent = True
|
||
session["logged_in"] = True
|
||
session["csrf_token"] = secrets.token_hex(16)
|
||
if (request.form.get("next") or "").strip() in ("/instance/create",):
|
||
flash("已登录。请再次点击「新建实例」。", "success")
|
||
return redirect(next_url)
|
||
flash("用户名或密码错误。", "error")
|
||
next_raw = (request.args.get("next") or request.form.get("next") or "")[:512]
|
||
return render_template(
|
||
"login.html",
|
||
next_url=next_raw,
|
||
default_admin_username=DEFAULT_ADMIN_USER,
|
||
)
|
||
|
||
|
||
@app.route("/logout", methods=["POST"])
|
||
def logout():
|
||
_check_csrf()
|
||
session.clear()
|
||
return redirect(url_for("login"))
|
||
|
||
|
||
@app.route("/")
|
||
@login_required
|
||
def index():
|
||
err_msg = _cli_compose_check()
|
||
ids = list_instance_ids()
|
||
rows = []
|
||
running_n = 0
|
||
for iid in ids:
|
||
st = instance_status(iid)
|
||
if st.get("running"):
|
||
running_n += 1
|
||
gpt, dpt = instance_ports(iid)
|
||
has_cfg = instance_has_config(iid)
|
||
rows.append(
|
||
{
|
||
"id": iid,
|
||
"status": st,
|
||
"metadata_owner": get_instance_owner(iid),
|
||
"gateway_port": gpt,
|
||
"dashboard_port": dpt,
|
||
"has_config": has_cfg,
|
||
}
|
||
)
|
||
return render_template(
|
||
"index.html",
|
||
rows=rows,
|
||
total=len(ids),
|
||
running_n=running_n,
|
||
data_root=str(DATA_ROOT),
|
||
compose_error=err_msg,
|
||
)
|
||
|
||
|
||
@app.route("/instance/<int:iid>/owner", methods=["POST"])
|
||
@login_required
|
||
def instance_set_owner(iid):
|
||
_check_csrf()
|
||
raw = (request.form.get("owner") or "").strip()[:_OWNER_MAX_LEN]
|
||
set_instance_owner(iid, raw)
|
||
if raw:
|
||
flash(f"实例 #{iid} 所属人已保存。", "success")
|
||
else:
|
||
flash(f"实例 #{iid} 所属人已清空。", "success")
|
||
return redirect(url_for("index"))
|
||
|
||
|
||
@app.route("/instance/create", methods=["POST"])
|
||
@login_required
|
||
def instance_create():
|
||
_check_csrf()
|
||
nid = next_instance_id()
|
||
ensure_instance_dirs(nid)
|
||
cp = _run_cli("bootstrap", str(nid))
|
||
if cp.returncode != 0:
|
||
flash(
|
||
f"创建实例 #{nid} 目录成功,但 bootstrap 失败:\n"
|
||
f"{cp.stderr or cp.stdout}",
|
||
"error",
|
||
)
|
||
else:
|
||
flash(f"已新建实例 #{nid} 并完成 bootstrap。可点击「启动」。", "success")
|
||
return redirect(url_for("index"))
|
||
|
||
|
||
@app.route("/instance/<int:iid>/run", methods=["POST"])
|
||
@login_required
|
||
def instance_run(iid):
|
||
_check_csrf()
|
||
ensure_instance_dirs(iid)
|
||
cp = _run_cli("run", "--quick", str(iid), timeout=_CLI_TIMEOUT_LONG)
|
||
if cp.returncode != 0:
|
||
err = (cp.stderr or cp.stdout or "").strip()
|
||
if "尚无镜像" in err or "compose-build" in err or "拉取" in err:
|
||
flash(
|
||
f"启动 #{iid} 失败:本地尚无镜像。\n"
|
||
f"请先点「拉取镜像」(或终端:./hermes-agent-cli.sh compose-build {iid}),完成后再点「启动」。",
|
||
"error",
|
||
)
|
||
elif cp.returncode == 124:
|
||
flash(f"启动 #{iid} 超时:\n{err}", "error")
|
||
else:
|
||
flash(f"启动 #{iid} 失败:\n{err}", "error")
|
||
else:
|
||
st = instance_status(iid)
|
||
if st.get("running"):
|
||
hint = st.get("status_text") or "running"
|
||
gpt, dpt = instance_ports(iid)
|
||
flash(
|
||
f"实例 #{iid} 已在运行(Hermes gateway)。Docker 状态:{hint}。"
|
||
f" API http://127.0.0.1:{gpt} · Dashboard http://127.0.0.1:{dpt}"
|
||
f" · 容器名一般为 hermes-{iid}-gateway-1。",
|
||
"success",
|
||
)
|
||
elif st.get("state") == "not_created":
|
||
flash(
|
||
f"compose 返回成功,但未查到网关容器(可能仍在创建)。"
|
||
f" 请在 Docker 中搜索 hermes-{iid} 或执行:"
|
||
f" docker ps -a --filter name=hermes-{iid}-gateway",
|
||
"warning",
|
||
)
|
||
else:
|
||
flash(
|
||
f"compose 已执行,当前探测状态:{st.get('state')}。"
|
||
f" {st.get('detail') or ''}"
|
||
f" 若容器已退出,查看日志:"
|
||
f" hermes-agent-cli.sh log {iid}",
|
||
"warning",
|
||
)
|
||
return redirect(url_for("index"))
|
||
|
||
|
||
@app.route("/instance/<int:iid>/compose-build", methods=["POST"])
|
||
@login_required
|
||
def instance_compose_build(iid):
|
||
_check_csrf()
|
||
cp = _run_cli("compose-build", str(iid), timeout=_CLI_TIMEOUT_LONG)
|
||
if cp.returncode == 124:
|
||
flash(
|
||
f"拉取 #{iid} 超过 {_CLI_TIMEOUT_LONG} 秒。可能仍在后台进行,"
|
||
f"请在终端执行:cd {HERMES_AGENT_HOME} && ./hermes-agent-cli.sh compose-build {iid}",
|
||
"warning",
|
||
)
|
||
elif cp.returncode != 0:
|
||
flash(f"拉取 #{iid} 失败:\n{cp.stderr or cp.stdout}", "error")
|
||
else:
|
||
out = (cp.stdout or "").strip()
|
||
if "跳过拉取" in out:
|
||
flash(f"实例 #{iid}:{out.splitlines()[0] if out else '镜像已存在,无需拉取。'} 可直接点「启动」。", "success")
|
||
else:
|
||
flash(f"实例 #{iid} 官方镜像已就绪,可点击「启动」。", "success")
|
||
return redirect(url_for("index"))
|
||
|
||
|
||
@app.route("/instance/<int:iid>/stop", methods=["POST"])
|
||
@login_required
|
||
def instance_stop(iid):
|
||
_check_csrf()
|
||
cp = _run_cli("stop", str(iid))
|
||
if cp.returncode != 0:
|
||
flash(f"停止 #{iid} 失败:\n{cp.stderr or cp.stdout}", "error")
|
||
else:
|
||
flash(f"实例 #{iid} 网关已停止。", "success")
|
||
return redirect(url_for("index"))
|
||
|
||
|
||
@app.route("/instance/<int:iid>/restart", methods=["POST"])
|
||
@login_required
|
||
def instance_restart(iid):
|
||
_check_csrf()
|
||
cp = _run_cli("restart", str(iid))
|
||
if cp.returncode != 0:
|
||
flash(f"重启 #{iid} 失败:\n{cp.stderr or cp.stdout}", "error")
|
||
else:
|
||
flash(f"实例 #{iid} 已重启。", "success")
|
||
return redirect(url_for("index"))
|
||
|
||
|
||
@app.route("/instance/<int:iid>/destroy", methods=["POST"])
|
||
@login_required
|
||
def instance_destroy(iid):
|
||
_check_csrf()
|
||
cp = _run_cli("destroy", str(iid))
|
||
if cp.returncode != 0:
|
||
flash(f"销毁 #{iid} 失败:\n{cp.stderr or cp.stdout}", "error")
|
||
else:
|
||
flash(
|
||
f"实例 #{iid} 已销毁:compose 项目已 down,宿主机上该实例目录(含 config/workspace)已删除。",
|
||
"success",
|
||
)
|
||
return redirect(url_for("index"))
|
||
|
||
|
||
@app.route("/instance/<int:iid>/config", methods=["GET", "POST"])
|
||
@login_required
|
||
def instance_config(iid):
|
||
path = config_path(iid)
|
||
if request.method == "POST":
|
||
_check_csrf()
|
||
text = request.form.get("config_body", "")
|
||
try:
|
||
import yaml # type: ignore
|
||
|
||
parsed = yaml.safe_load(text)
|
||
if parsed is not None and not isinstance(parsed, dict):
|
||
raise ValueError("根节点须为 YAML 映射(mapping)")
|
||
except ImportError:
|
||
pass
|
||
except Exception as e:
|
||
flash(f"YAML 无效:{e}", "error")
|
||
return render_template("config_edit.html", iid=iid, text=text)
|
||
backup = path.with_suffix(".yaml.bak")
|
||
if path.is_file():
|
||
shutil.copy2(path, backup)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
|
||
flash(
|
||
"已保存 config.yaml(旧文件已备份为 config.yaml.bak)。修改后请在列表页重启 gateway。",
|
||
"success",
|
||
)
|
||
return redirect(url_for("instance_config", iid=iid))
|
||
if path.is_file():
|
||
text = path.read_text(encoding="utf-8")
|
||
else:
|
||
text = "# Hermes Agent config.yaml\nmodel:\n provider: anthropic\n"
|
||
flash("该实例尚无 config.yaml,编辑保存后将创建。", "warning")
|
||
return render_template("config_edit.html", iid=iid, text=text)
|
||
|
||
|
||
def _command_pty_bridge(ws, iid: int, title: str, command_factory) -> None:
|
||
"""通用 WebSocket ↔ 伪终端桥接。"""
|
||
assert pexpect is not None
|
||
env = os.environ.copy()
|
||
env.setdefault("TERM", "xterm-256color")
|
||
env.setdefault("COLORTERM", "truecolor")
|
||
env.setdefault("BASH_SILENCE_DEPRECATION_WARNING", "1")
|
||
bash = shutil.which("bash") or "/bin/bash"
|
||
inner, hint = command_factory(iid)
|
||
child = pexpect.spawn(
|
||
bash,
|
||
["--noprofile", "--norc", "-c", inner],
|
||
cwd=str(HERMES_AGENT_HOME),
|
||
env=env,
|
||
echo=False,
|
||
encoding="utf-8",
|
||
codec_errors="replace",
|
||
timeout=None,
|
||
)
|
||
child.setwinsize(24, 100)
|
||
|
||
banner = (
|
||
f"\r\n\x1b[36m══ {title}(实例 {iid})══\x1b[0m\r\n"
|
||
f"\x1b[90m{hint}\x1b[0m\r\n\r\n"
|
||
)
|
||
try:
|
||
ws.send(banner)
|
||
except Exception:
|
||
pass
|
||
|
||
def forward_pty() -> None:
|
||
while True:
|
||
try:
|
||
chunk = child.read_nonblocking(65536, timeout=0.25)
|
||
if chunk:
|
||
ws.send(chunk)
|
||
except pexpect.TIMEOUT:
|
||
continue
|
||
except (pexpect.EOF, OSError):
|
||
try:
|
||
ws.send("\r\n\x1b[33m( shell 已退出,可关闭本页 )\x1b[0m\r\n")
|
||
except Exception:
|
||
pass
|
||
break
|
||
|
||
threading.Thread(target=forward_pty, daemon=True).start()
|
||
|
||
try:
|
||
while True:
|
||
msg = ws.receive()
|
||
if msg is None:
|
||
break
|
||
if isinstance(msg, bytes):
|
||
msg = msg.decode("utf-8", errors="replace")
|
||
if len(msg) > 2 and msg[0] == "{" and msg[-1] == "}":
|
||
try:
|
||
o = json.loads(msg)
|
||
if o.get("t") == "resize":
|
||
rows = max(5, min(int(o["rows"]), 200))
|
||
cols = max(20, min(int(o["cols"]), 500))
|
||
child.setwinsize(rows, cols)
|
||
continue
|
||
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||
pass
|
||
try:
|
||
child.send(msg)
|
||
except OSError:
|
||
break
|
||
finally:
|
||
try:
|
||
child.close(force=True)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
@sock.route("/ws/instance/<int:iid>/pty")
|
||
def guide_pty_ws(ws, iid: int):
|
||
"""引导终端 WebSocket(签名 token,避免 WS 握手丢失 session)。"""
|
||
tok = request.args.get("token", type=str)
|
||
if not verify_guide_pty_token(tok, iid):
|
||
try:
|
||
ws.send(
|
||
"\r\n\x1b[31m[未授权:请从首页「Hermes 引导」重新进入"
|
||
"(链接 15 分钟内有效)]\x1b[0m\r\n"
|
||
)
|
||
except Exception:
|
||
pass
|
||
return
|
||
if sys.platform == "win32":
|
||
try:
|
||
ws.send("\r\n[网页终端仅支持 macOS / Linux]\r\n")
|
||
except Exception:
|
||
pass
|
||
return
|
||
if pexpect is None:
|
||
try:
|
||
ws.send("\r\n[缺少依赖: pip install pexpect]\r\n")
|
||
except Exception:
|
||
pass
|
||
return
|
||
_command_pty_bridge(ws, iid, "Hermes 引导", _guide_shell_command)
|
||
|
||
|
||
@sock.route("/ws/instance/<int:iid>/docker-pty")
|
||
def docker_pty_ws(ws, iid: int):
|
||
"""Docker 容器终端 WebSocket(签名 token)。"""
|
||
tok = request.args.get("token", type=str)
|
||
if not verify_docker_pty_token(tok, iid):
|
||
try:
|
||
ws.send(
|
||
"\r\n\x1b[31m[未授权:请从首页「终端」重新进入"
|
||
"(链接 15 分钟内有效)]\x1b[0m\r\n"
|
||
)
|
||
except Exception:
|
||
pass
|
||
return
|
||
if sys.platform == "win32":
|
||
try:
|
||
ws.send("\r\n[网页终端仅支持 macOS / Linux]\r\n")
|
||
except Exception:
|
||
pass
|
||
return
|
||
if pexpect is None:
|
||
try:
|
||
ws.send("\r\n[缺少依赖: pip install pexpect]\r\n")
|
||
except Exception:
|
||
pass
|
||
return
|
||
_command_pty_bridge(ws, iid, "Docker 终端", _docker_shell_command)
|
||
|
||
|
||
@app.route("/instance/<int:iid>/guide")
|
||
@login_required
|
||
def instance_guide(iid):
|
||
"""引导终端:自动执行 setup(如需)→ 启动网关 → onboard,用户只应答交互提示。"""
|
||
if sys.platform == "win32":
|
||
flash("网页引导终端仅支持 macOS / Linux。", "error")
|
||
return redirect(url_for("index"))
|
||
if pexpect is None:
|
||
flash("请安装依赖:pip install -r requirements.txt(含 pexpect、flask-sock)", "error")
|
||
return redirect(url_for("index"))
|
||
return render_template(
|
||
"guide_terminal.html",
|
||
iid=iid,
|
||
ws_token=make_guide_pty_token(iid),
|
||
)
|
||
|
||
|
||
@app.route("/instance/<int:iid>/docker")
|
||
@login_required
|
||
def instance_docker_terminal(iid):
|
||
"""运行中实例的 Docker 容器终端。"""
|
||
if sys.platform == "win32":
|
||
flash("网页终端仅支持 macOS / Linux。", "error")
|
||
return redirect(url_for("index"))
|
||
if pexpect is None:
|
||
flash("请安装依赖:pip install -r requirements.txt(含 pexpect、flask-sock)", "error")
|
||
return redirect(url_for("index"))
|
||
return render_template(
|
||
"docker_terminal.html",
|
||
iid=iid,
|
||
ws_token=make_docker_pty_token(iid),
|
||
)
|
||
|
||
|
||
@app.route("/instance/<int:iid>/channels")
|
||
@app.route("/instance/<int:iid>/channels/term")
|
||
@login_required
|
||
def legacy_channels_redirect(iid):
|
||
return redirect(url_for("instance_guide", iid=iid))
|
||
|
||
|
||
def main():
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s %(message)s",
|
||
datefmt="%Y-%m-%d %H:%M:%S",
|
||
)
|
||
ap = argparse.ArgumentParser(description="Hermes Agent Web 管理端")
|
||
ap.add_argument("--host", default=None, help="监听地址(默认同 HERMES_AGENT_WEB_HOST,127.0.0.1)")
|
||
ap.add_argument("--port", type=int, default=None, help="端口(默认同 HERMES_AGENT_WEB_PORT,8781)")
|
||
ap.add_argument(
|
||
"--admin-password",
|
||
default="",
|
||
help="管理员登录密码,写入 HERMES_AGENT_WEB_ADMIN_PASSWORD(与 df-report --admin-password 类似)",
|
||
)
|
||
args = ap.parse_args()
|
||
if args.admin_password:
|
||
os.environ["HERMES_AGENT_WEB_ADMIN_PASSWORD"] = args.admin_password
|
||
_reload_admin_password_config()
|
||
host = (
|
||
args.host
|
||
if args.host is not None
|
||
else os.environ.get("HERMES_AGENT_WEB_HOST", "127.0.0.1")
|
||
)
|
||
port = (
|
||
int(args.port)
|
||
if args.port is not None
|
||
else int(os.environ.get("HERMES_AGENT_WEB_PORT", "8781"))
|
||
)
|
||
if not ADMIN_PASSWORD_HASH and _raw_pw == "":
|
||
print(
|
||
"警告: 未设置 HERMES_AGENT_WEB_ADMIN_PASSWORD,登录将不可用。"
|
||
" 可: HERMES_AGENT_WEB_ADMIN_PASSWORD=你的密码 python app.py"
|
||
" 或: python app.py --admin-password 你的密码",
|
||
flush=True,
|
||
)
|
||
print(f"Hermes Agent Web: http://{host}:{port}", flush=True)
|
||
app.run(host=host, port=port, debug=False, use_reloader=False, threaded=True)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|