Files
hermes-agent-home/hermes-agent-web/app.py
T
carlandCursor 08b25e8f87 移除 OpenClaw/ClawHub 遗留并更新文档
删除 allowall.sh、旧版 CLI/Web 路由,README 改为 Hermes 专用说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 14:54:14 +08:00

921 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 与 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(如需)→ 启动网关 → 容器 shell,用户只应答交互提示。"""
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),
)
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()