💋 Sync 2026-10-04 04:16:09

This commit is contained in:
github-actions[bot] committed 2026-10-04 04:16:09 +08:00
1 parent f6b2df73ec
commit f62992fd80
745 files changed
+212416 -1173

No files matched your search

+447
View File
@@ -0,0 +1,447 @@
#!/bin/sh
# ============================================================================
# hermes-env — Hermes Agent environment installation management script
# Usage: hermes-env setup | check | upgrade
# Environment variables: HERMES_VERSION — specify version to install
# ============================================================================
# ── Version and path constants ─────────────────────────────────────────────
HERMES_BASE="/opt/hermes"
HERMES_VENV="${HERMES_BASE}/venv"
HERMES_DATA="${HERMES_BASE}/data"
HERMES_BIN="${HERMES_VENV}/bin/hermes"
PYTHON_BIN="${HERMES_VENV}/bin/python3"
HERMES_CONFIG="/root/.hermes/config.yaml"
# PyPI mirrors
HERMES_PYPI_CN="https://mirrors.aliyun.com/pypi/simple/"
HERMES_PYPI_OFFICIAL="https://pypi.org/simple/"
HERMES_PYPI_TSINGHUA="https://pypi.tuna.tsinghua.edu.cn/simple/"
# Default API endpoint
HERMES_DEFAULT_API="https://api.boos.lat/v1"
HERMES_DEFAULT_MODEL="opus4.6"
export PATH="${HERMES_VENV}/bin:$PATH"
# ── Logging tools ──────────────────────────────────────────────────────────
log_ok() { echo " [✓] $*"; }
log_warn() { echo " [!] $*"; }
log_err() { echo " [✗] $*"; }
log_step() { echo ""; echo "=== $* ==="; }
# ── OverlayFS fix (iStoreOS Docker /opt conflict) ─────────────────────────
_fix_opt() {
mkdir -p /opt/hermes/.probe 2>/dev/null && { rmdir /opt/hermes/.probe 2>/dev/null; return 0; }
[ -d /overlay/upper/opt ] && mkdir -p /overlay/upper/opt/hermes 2>/dev/null && \
mount --bind /overlay/upper/opt /opt 2>/dev/null && return 0
return 1
}
_fix_opt
# ── Basic tools ────────────────────────────────────────────────────────────
ensure_dir() { [ -d "$1" ] && return 0; mkdir -p "$1" 2>/dev/null || { log_err "Cannot create directory: $1"; return 1; }; }
# Check available disk space
_df_avail() {
local p="$1"
while [ -n "$p" ] && [ "$p" != "/" ]; do
[ -e "$p" ] && break
p="${p%/*}"
done
[ -z "$p" ] && p="/"
df -m "$p" 2>/dev/null | tail -1 | awk '{print $(NF-2)}'
}
check_space() {
local path="$1" need_mb="$2" label="$3"
local avail
avail=$(_df_avail "$path")
[ -z "$avail" ] && return 0
if [ "$avail" -lt "$need_mb" ]; then
log_warn "${label} available space insufficient: ${avail}MB < ${need_mb}MB recommended"
else
log_ok "${label} available space: ${avail}MB"
fi
}
# Download: wget first, curl fallback
_download() {
local url="$1" out="$2"
if command -v wget >/dev/null 2>&1; then
wget -q --no-check-certificate -O "$out" "$url" 2>/dev/null
elif command -v curl >/dev/null 2>&1; then
curl -fsSL --connect-timeout 15 --max-time 120 -o "$out" "$url" 2>/dev/null
else
log_err "No download tool available (wget/curl)"; return 1
fi
}
# Try multiple mirrors, return first success
_download_first() {
local out="$1"; shift
for url in "$@"; do
[ -z "$url" ] && continue
if _download "$url" "$out"; then
local sz; sz=$(wc -c < "$out" 2>/dev/null || echo 0)
[ "$sz" -gt 1000 ] && return 0
fi
rm -f "$out"
done
return 1
}
# ── Architecture detection ─────────────────────────────────────────────────
detect_arch() {
case "$(uname -m)" in
x86_64) echo "x86_64" ;;
aarch64) echo "aarch64" ;;
armv7l) echo "armv7l" ;;
armv6l) echo "armv6l" ;;
*) log_err "Unsupported architecture: $(uname -m)"; exit 1 ;;
esac
}
detect_libc() {
[ -f /etc/openwrt_release ] && echo "musl" && return
for f in /lib/ld-musl-*.so.1; do [ -f "$f" ] && echo "musl" && return; done
echo "glibc"
}
# ── Install Python 3 ──────────────────────────────────────────────────────
install_python() {
# Check if system python3 is available
if command -v python3 >/dev/null 2>&1; then
local ver
ver=$(python3 --version 2>/dev/null | awk '{print $2}')
log_ok "System Python3 v${ver} found"
# Check if it's recent enough (3.8+)
local major minor
major=$(echo "$ver" | cut -d. -f1)
minor=$(echo "$ver" | cut -d. -f2)
if [ "$major" -ge 3 ] && [ "$minor" -ge 8 ] 2>/dev/null; then
log_ok "Python3 v${ver} meets requirements (>= 3.8)"
return 0
fi
log_warn "Python3 v${ver} is too old, need >= 3.8"
fi
log_step "Installing Python 3"
# Try opkg first
opkg update >/dev/null 2>&1 || true
opkg install python3 python3-pip python3-venv 2>/dev/null | tail -5 || true
if command -v python3 >/dev/null 2>&1; then
log_ok "Python3 installed via opkg: $(python3 --version 2>/dev/null)"
return 0
fi
# Fallback: try to install minimal python3
opkg install python3-base python3-light 2>/dev/null | tail -5 || true
if command -v python3 >/dev/null 2>&1; then
log_ok "Python3 (minimal) installed: $(python3 --version 2>/dev/null)"
# Also need pip
opkg install python3-pip 2>/dev/null || true
return 0
fi
log_err "Python3 installation failed"
log_err "Please install manually: opkg install python3 python3-pip python3-venv"
return 1
}
# ── Create virtual environment ─────────────────────────────────────────────
create_venv() {
log_step "Creating Python virtual environment"
ensure_dir "$HERMES_BASE" || exit 1
# Remove old venv if exists
if [ -d "$HERMES_VENV" ]; then
log_warn "Removing old virtual environment..."
rm -rf "$HERMES_VENV"
fi
# Create venv using system python3
local sys_python
sys_python=$(command -v python3 2>/dev/null)
if [ -z "$sys_python" ]; then
log_err "python3 not found in PATH"
return 1
fi
"$sys_python" -m venv "$HERMES_VENV" 2>/dev/null
if [ ! -x "$PYTHON_BIN" ]; then
# Try without -m venv (older python)
log_warn "python3 -m venv failed, trying virtualenv..."
pip3 install virtualenv 2>/dev/null || true
"$sys_python" -m virtualenv "$HERMES_VENV" 2>/dev/null
fi
if [ ! -x "$PYTHON_BIN" ]; then
log_err "Failed to create virtual environment"
return 1
fi
log_ok "Virtual environment created at $HERMES_VENV"
# Upgrade pip
log_ok "Upgrading pip..."
"$PYTHON_BIN" -m pip install --upgrade pip 2>/dev/null | tail -2 || true
log_ok "Python: $("$PYTHON_BIN" --version 2>/dev/null)"
}
# ── Install Hermes Agent ───────────────────────────────────────────────────
install_hermes() {
log_step "Installing Hermes Agent"
# Check if already installed
if [ -x "$HERMES_BIN" ]; then
local cur
cur=$("$HERMES_BIN" --version 2>/dev/null | head -1 | tr -d '[:space:]')
log_ok "Hermes Agent v${cur} already installed"
echo " To reinstall: hermes-env setup --force"
return 0
fi
ensure_dir "$HERMES_VENV" || return 1
# Try PyPI mirrors in order
local install_ok=false
for mirror in "$HERMES_PYPI_CN" "$HERMES_PYPI_TSINGHUA" "$HERMES_PYPI_OFFICIAL"; do
log_warn "Trying: ${mirror}"
if "$PYTHON_BIN" -m pip install hermes-agent \
-i "$mirror" \
--trusted-host "$(echo "$mirror" | sed 's|https://||;s|/.*||')" \
2>&1 | tail -10; then
install_ok=true
break
fi
done
if [ "$install_ok" = "false" ] || [ ! -x "$HERMES_BIN" ]; then
# Try without mirror flags
log_warn "Mirror install failed, trying default PyPI..."
"$PYTHON_BIN" -m pip install hermes-agent 2>&1 | tail -10 || true
[ -x "$HERMES_BIN" ] && install_ok=true
fi
if [ "$install_ok" = "false" ]; then
log_err "Hermes Agent installation failed"
log_err "Please check network connection and try again"
return 1
fi
log_ok "Hermes Agent installed: $("$HERMES_BIN" --version 2>/dev/null | head -1)"
}
# ── Initialize Hermes configuration ────────────────────────────────────────
init_hermes() {
log_step "Initializing Hermes configuration"
mkdir -p /root/.hermes 2>/dev/null
# Generate auth token
local token
token=$(uci -q get hermes.main.token 2>/dev/null || echo "")
if [ -z "$token" ]; then
token=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || openssl rand -hex 24 2>/dev/null || echo "hermes_$(date +%s)")
uci set hermes.main.token="$token" 2>/dev/null || true
uci commit hermes 2>/dev/null || true
fi
# Generate PTY token
local pty_token
pty_token=$(uci -q get hermes.main.pty_token 2>/dev/null || echo "")
if [ -z "$pty_token" ]; then
pty_token=$(head -c 16 /dev/urandom | hexdump -e '16/1 "%02x"' 2>/dev/null || openssl rand -hex 16 2>/dev/null || echo "pty_$(date +%s)")
uci set hermes.main.pty_token="$pty_token" 2>/dev/null || true
uci commit hermes 2>/dev/null || true
fi
# Write default config.yaml
local port bind api_endpoint api_key model
port=$(uci -q get hermes.main.port || echo "3000")
bind=$(uci -q get hermes.main.bind || echo "lan")
api_endpoint=$(uci -q get hermes.main.api_endpoint || echo "$HERMES_DEFAULT_API")
api_key=$(uci -q get hermes.main.api_key || echo "")
model=$(uci -q get hermes.main.model || echo "$HERMES_DEFAULT_MODEL")
local listen_host="0.0.0.0"
[ "$bind" = "loopback" ] && listen_host="127.0.0.1"
cat > /root/.hermes/config.yaml << YAMLEOF
# Hermes Agent configuration
# Initialized by luci-app-hermes on $(date '+%Y-%m-%d %H:%M:%S')
gateway:
port: ${port}
bind: ${listen_host}
auth:
mode: token
token: ${token}
api:
endpoint: "${api_endpoint}"
key: "${api_key}"
model:
primary: "${model}"
tools:
profile: coding
update:
checkOnStart: false
YAMLEOF
chmod 600 /root/.hermes/config.yaml 2>/dev/null || true
log_ok "Configuration written to /root/.hermes/config.yaml"
}
# ── Command: setup ─────────────────────────────────────────────────────────
cmd_setup() {
check_space /opt 100 "Install directory (/opt)"
check_space /tmp 100 "Temp directory (/tmp)"
# Already installed?
if [ -x "$HERMES_BIN" ]; then
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ [!] Hermes Agent already installed ║"
echo "║ To reinstall, run: hermes-env setup --force ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo " Hermes: $("$HERMES_BIN" --version 2>/dev/null | head -1)"
exit 0
fi
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Hermes Agent Environment Setup ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo " Architecture: $(uname -m)"
echo " Libc: $(detect_libc)"
echo ""
install_python || exit 1
create_venv || exit 1
install_hermes || exit 1
init_hermes || exit 1
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Setup Complete! ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo " Hermes: $("$HERMES_BIN" --version 2>/dev/null | head -1)"
echo " Python: $("$PYTHON_BIN" --version 2>/dev/null)"
echo " Config: /root/.hermes/config.yaml"
echo ""
echo " Next steps:"
echo " 1. Edit /etc/config/hermes to set your API key"
echo " 2. Run: /etc/init.d/hermes enable"
echo " 3. Run: /etc/init.d/hermes start"
echo ""
echo " Or use the LuCI web interface to configure and start."
}
# ── Command: check ─────────────────────────────────────────────────────────
cmd_check() {
echo "=== Hermes Environment Check ==="
if [ -x "$PYTHON_BIN" ]; then
echo "Python: $("$PYTHON_BIN" --version 2>/dev/null)"
else
echo "Python: Not installed (virtual env)"
fi
if command -v python3 >/dev/null 2>&1; then
echo "System Py: $(python3 --version 2>/dev/null)"
else
echo "System Py: Not found"
fi
if [ -x "$HERMES_BIN" ]; then
echo "Hermes: $("$HERMES_BIN" --version 2>/dev/null | head -1)"
else
echo "Hermes: Not installed"
fi
echo "VENV: $HERMES_VENV"
echo "Config: $HERMES_CONFIG"
if [ -f "$HERMES_CONFIG" ]; then
echo "Config OK: Yes"
else
echo "Config OK: No (will be created on start)"
fi
}
# ── Command: upgrade ───────────────────────────────────────────────────────
cmd_upgrade() {
if [ ! -x "$HERMES_BIN" ]; then
log_err "Hermes Agent not installed. Run: hermes-env setup"
exit 1
fi
log_step "Upgrading Hermes Agent"
local upgrade_ok=false
for mirror in "$HERMES_PYPI_CN" "$HERMES_PYPI_TSINGHUA" "$HERMES_PYPI_OFFICIAL"; do
log_warn "Trying: ${mirror}"
if "$PYTHON_BIN" -m pip install --upgrade hermes-agent \
-i "$mirror" \
--trusted-host "$(echo "$mirror" | sed 's|https://||;s|/.*||')" \
2>&1 | tail -10; then
upgrade_ok=true
break
fi
done
if [ "$upgrade_ok" = "false" ]; then
log_warn "Mirror install failed, trying default PyPI..."
"$PYTHON_BIN" -m pip install --upgrade hermes-agent 2>&1 | tail -10 || true
[ -x "$HERMES_BIN" ] && upgrade_ok=true
fi
if [ "$upgrade_ok" = "true" ] && [ -x "$HERMES_BIN" ]; then
log_ok "Hermes upgraded: $("$HERMES_BIN" --version 2>/dev/null | head -1)"
else
log_err "Upgrade failed"
exit 1
fi
}
# ── Command: uninstall ─────────────────────────────────────────────────────
cmd_uninstall() {
log_step "Uninstalling Hermes Agent"
/etc/init.d/hermes stop 2>/dev/null || true
/etc/init.d/hermes disable 2>/dev/null || true
uci set hermes.main.enabled=0 2>/dev/null || true
uci commit hermes 2>/dev/null || true
[ -d "$HERMES_BASE" ] && { rm -rf "$HERMES_BASE" 2>/dev/null || true; }
[ -d "/root/.hermes" ] && { rm -rf "/root/.hermes" 2>/dev/null || true; }
log_ok "Hermes Agent uninstalled"
}
# ── Main entry ─────────────────────────────────────────────────────────────
case "${1:-setup}" in
setup) cmd_setup ;;
check) cmd_check ;;
upgrade) cmd_upgrade ;;
uninstall) cmd_uninstall ;;
*)
echo "Usage: hermes-env {setup|check|upgrade|uninstall}"
echo ""
echo " setup Install Python + Hermes Agent (default)"
echo " check Check installation status"
echo " upgrade Upgrade Hermes Agent to latest version"
echo " uninstall Remove all Hermes files"
exit 1
;;
esac
@@ -1,67 +0,0 @@
#!/bin/sh
ACTION=${1}
shift 1
do_install() {
local port=$(uci get hermes.@main[0].port 2>/dev/null | tr -d '\n\r')
local data=$(uci get hermes.@main[0].data_path 2>/dev/null | tr -d '\n\r')
local workspace=$(uci get hermes.@main[0].workspace_path 2>/dev/null | tr -d '\n\r')
local image_name="linkease/hermes:latest"
echo "docker pull ${image_name}"
docker pull ${image_name}
docker rm -f hermes
[ -z "$port" ] && port=8787
local cmd="docker run --restart=unless-stopped -d"
[ -n "$data" ] && cmd="$cmd -v \"$data:/data/.hermes\""
[ -n "$workspace" ] && cmd="$cmd -v \"$workspace:/workspace\""
cmd="$cmd -p $port:8787"
local tz=$(uci get system.@system[0].zonename 2>/dev/null | sed 's/ /_/g')
[ -z "$tz" ] || cmd="$cmd -e TZ=$tz"
cmd="$cmd --name hermes \"$image_name\""
echo "$cmd"
eval "$cmd"
}
usage() {
echo "usage: $0 sub-command"
echo "where sub-command is one of:"
echo " install Install the hermes"
echo " upgrade Upgrade the hermes"
echo " rm/start/stop/restart Remove/Start/Stop/Restart the hermes"
echo " status Hermes status"
echo " port Hermes port"
}
case ${ACTION} in
"install")
do_install
;;
"upgrade")
do_install
;;
"rm")
docker rm -f hermes
;;
"start" | "stop" | "restart")
docker ${ACTION} hermes
;;
"status")
docker ps --all -f 'name=^/hermes$' --format '{{.State}}'
;;
"port")
docker ps --all -f 'name=^/hermes$' --format '{{.Ports}}' | grep -om1 '0.0.0.0:[0-9]*->8787/tcp' | sed 's/0.0.0.0:\([0-9]*\)->.*/\1/'
;;
*)
usage
exit 1
;;
esac
+210
View File
@@ -0,0 +1,210 @@
#!/bin/sh
# luci-app-hermes — Backend helper for JS view
# Called via ubus file.exec from the LuCI frontend
. /lib/functions.sh 2>/dev/null || true
HERMES_VENV="/opt/hermes/venv"
HERMES_BIN="${HERMES_VENV}/bin/hermes"
HERMES_DATA="/opt/hermes/data"
HERMES_CONFIG="/root/.hermes/config.yaml"
# ── JSON helper: escape string for JSON output ──
json_esc() {
printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g' | tr '\n' ' '
}
# ── Port listening check (ss or netstat) ──
port_listening() {
local p="$1"
if command -v ss >/dev/null 2>&1; then
ss -tulnp 2>/dev/null | grep -q ":${p} "
else
netstat -tulnp 2>/dev/null | grep -q ":${p} "
fi
}
pid_by_port() {
local p="$1"
if command -v ss >/dev/null 2>&1; then
ss -tulnp 2>/dev/null | grep ":${p} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1
else
netstat -tulnp 2>/dev/null | grep ":${p} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1
fi
}
case "$1" in
# ═══════════════════════════════════════════
# STATUS — gather all status info as JSON
# ═══════════════════════════════════════════
status)
enabled=$(uci -q get hermes.main.enabled || echo "0")
port=$(uci -q get hermes.main.port || echo "3000")
pty_port=$(uci -q get hermes.main.pty_port || echo "3001")
# Validate ports
case "$port" in *[!0-9]*) port="3000" ;; esac
case "$pty_port" in *[!0-9]*) pty_port="3001" ;; esac
# Gateway check
gw_running=false
gw_starting=false
port_listening "$port" && gw_running=true
if [ "$gw_running" = "false" ] && [ "$enabled" = "1" ]; then
pgrep -f 'hermes.*gateway' >/dev/null 2>&1 && gw_starting=true
fi
# PTY check
pty_running=false
port_listening "$pty_port" && pty_running=true
# Python version
python_ver=""
if [ -x "${HERMES_VENV}/bin/python3" ]; then
python_ver=$("${HERMES_VENV}/bin/python3" --version 2>/dev/null | tr -d '[:space:]')
elif command -v python3 >/dev/null 2>&1; then
python_ver=$(python3 --version 2>/dev/null | tr -d '[:space:]')
fi
# Hermes version
hermes_ver=""
if [ -x "$HERMES_BIN" ]; then
hermes_ver=$("$HERMES_BIN" --version 2>/dev/null | head -1 | tr -d '[:space:]')
fi
# Plugin version
plugin_ver=""
[ -f "/usr/share/hermes/VERSION" ] && plugin_ver=$(cat /usr/share/hermes/VERSION 2>/dev/null | tr -d '[:space:]')
# PID, memory, uptime
pid="" mem_kb=0 uptime_str=""
if [ "$gw_running" = "true" ]; then
pid=$(pid_by_port "$port")
if [ -n "$pid" ] && [ -d "/proc/$pid" ]; then
mem_kb=$(awk '/VmRSS/{print $2}' "/proc/$pid/status" 2>/dev/null)
[ -z "$mem_kb" ] && mem_kb=0
# Calculate uptime from /proc/$pid directory mtime
start_ts=$(date -r "/proc/$pid" +%s 2>/dev/null)
if [ -n "$start_ts" ]; then
now=$(date +%s)
diff=$((now - start_ts))
h=$((diff / 3600)) m=$(((diff % 3600) / 60)) s=$((diff % 60))
if [ $h -gt 0 ]; then uptime_str="${h}h ${m}m"
elif [ $m -gt 0 ]; then uptime_str="${m}m ${s}s"
else uptime_str="${s}s"; fi
fi
fi
fi
# Active model (from config.yaml)
active_model=""
if [ -f "$HERMES_CONFIG" ]; then
active_model=$(sed -n 's/.*primary: *"\{0,1\}\([^"]*\)"\{0,1\}/\1/p' "$HERMES_CONFIG" 2>/dev/null | head -1)
fi
cat <<JSON
{
"enabled": "$enabled",
"port": "$port",
"pty_port": "$pty_port",
"gateway_running": $gw_running,
"gateway_starting": $gw_starting,
"pty_running": $pty_running,
"python_version": "$python_ver",
"hermes_version": "$hermes_ver",
"plugin_version": "$plugin_ver",
"pid": "$pid",
"memory_kb": ${mem_kb:-0},
"uptime": "$uptime_str",
"active_model": "$(json_esc "$active_model")"
}
JSON
;;
# ═══════════════════════════════════════════
# SETUP — install Python + Hermes Agent
# ═══════════════════════════════════════════
setup)
version="${2:-stable}"
# Clean old state
: > /tmp/hermes-setup.log
: > /tmp/hermes-setup.exit
: > /tmp/hermes-setup.pid
( /usr/bin/hermes-env setup > /tmp/hermes-setup.log 2>&1
RC=$?
echo $RC > /tmp/hermes-setup.exit
if [ $RC -eq 0 ]; then
uci set hermes.main.enabled=1
uci commit hermes
/etc/init.d/hermes enable 2>/dev/null
sleep 1
/etc/init.d/hermes start >> /tmp/hermes-setup.log 2>&1
fi
) &
echo $! > /tmp/hermes-setup.pid
echo '{"status":"ok","message":"setup started"}'
;;
# ═══════════════════════════════════════════
# SETUP_LOG — poll setup progress
# ═══════════════════════════════════════════
setup_log)
log=""
[ -f /tmp/hermes-setup.log ] && log=$(cat /tmp/hermes-setup.log 2>/dev/null)
running=false
if [ -f /tmp/hermes-setup.pid ]; then
pid=$(cat /tmp/hermes-setup.pid 2>/dev/null | tr -d '[:space:]')
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && running=true
fi
exit_code=-1
if [ "$running" = "false" ] && [ -f /tmp/hermes-setup.exit ]; then
exit_code=$(cat /tmp/hermes-setup.exit 2>/dev/null | tr -d '[:space:]')
[ -z "$exit_code" ] && exit_code=-1
fi
state="idle"
if [ "$running" = "true" ]; then state="running"
elif [ "$exit_code" = "0" ]; then state="success"
elif [ "$exit_code" -gt 0 ] 2>/dev/null; then state="failed"
fi
# Escape log for JSON
log_escaped=$(printf '%s' "$log" | sed 's/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g' | tr '\n' '\\' | sed 's/\\/\\n/g')
printf '{"state":"%s","exit_code":%s,"log":"%s"}\n' "$state" "${exit_code}" "$log_escaped"
;;
# ═══════════════════════════════════════════
# GET_TOKEN — return gateway and PTY tokens
# ═══════════════════════════════════════════
get_token)
token=$(uci -q get hermes.main.token || echo "")
pty_token=$(uci -q get hermes.main.pty_token || echo "")
printf '{"token":"%s","pty_token":"%s"}\n' "$token" "$pty_token"
;;
# ═══════════════════════════════════════════
# UNINSTALL — remove environment
# ═══════════════════════════════════════════
uninstall)
/etc/init.d/hermes stop >/dev/null 2>&1
/etc/init.d/hermes disable 2>/dev/null
uci set hermes.main.enabled=0
uci commit hermes 2>/dev/null
[ -d /opt/hermes ] && { find /opt/hermes -delete 2>/dev/null || true; }
[ -d /root/.hermes ] && { find /root/.hermes -delete 2>/dev/null || true; }
: > /tmp/hermes-setup.log 2>/dev/null
: > /tmp/hermes-setup.pid 2>/dev/null
: > /tmp/hermes-setup.exit 2>/dev/null
echo '{"status":"ok","message":"uninstalled"}'
;;
*)
echo '{"status":"error","message":"unknown command"}'
;;
esac
@@ -0,0 +1,278 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Hermes Agent Terminal</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#1a1b26;font-family:system-ui,-apple-system,sans-serif}
#loading{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#7aa2f7;z-index:100;background:#1a1b26;transition:opacity .4s}
#loading.hidden{opacity:0;pointer-events:none}
#loading .spinner{width:40px;height:40px;border:3px solid #2a2b3d;border-top:3px solid #ff9e64;border-radius:50%;animation:spin .8s linear infinite;margin-bottom:16px}
@keyframes spin{to{transform:rotate(360deg)}}
#loading h2{font-size:18px;font-weight:500;margin-bottom:8px}
#loading p{font-size:13px;color:#565f89}
#loading .debug{display:none}
#terminal-container{width:100%;height:calc(100% - 36px);position:absolute;top:36px;left:0;right:0;bottom:0;padding:4px;overflow:hidden}
.xterm{height:100%!important}
.xterm-viewport::-webkit-scrollbar{width:8px}
.xterm-viewport::-webkit-scrollbar-thumb{background:#2a2b3d;border-radius:4px}
.xterm-viewport::-webkit-scrollbar-thumb:hover{background:#3b3d57}
#topbar{position:fixed;top:0;left:0;right:0;height:36px;background:#16161e;border-bottom:1px solid #2a2b3d;display:flex;align-items:center;padding:0 12px;z-index:50;gap:8px}
#topbar .logo{font-size:14px;color:#ff9e64;font-weight:600}
#topbar .status{font-size:11px;padding:2px 8px;border-radius:10px;background:#2a2b3d}
#topbar .status.connected{color:#9ece6a}
#topbar .status.disconnected{color:#f7768e}
#topbar .btn{font-size:12px;color:#7aa2f7;background:none;border:1px solid #3b3d57;border-radius:4px;padding:3px 10px;cursor:pointer;margin-left:auto}
#topbar .btn:hover{background:#2a2b3d}
#reconnect-overlay{display:none;position:absolute;inset:0;background:rgba(26,27,38,.9);z-index:80;flex-direction:column;align-items:center;justify-content:center;color:#c0caf5}
#reconnect-overlay.show{display:flex}
#reconnect-overlay button{margin-top:16px;padding:8px 24px;background:#ff9e64;color:#1a1b26;border:none;border-radius:6px;font-size:14px;cursor:pointer;font-weight:600}
#reconnect-overlay button:hover{background:#e0884a}
</style>
</head>
<body>
<div id="topbar">
<span class="logo">🤖 Hermes Agent Terminal</span>
<span id="status" class="status disconnected">● Connecting...</span>
<button class="btn" onclick="location.reload()" title="Restart terminal">🔄 Restart</button>
</div>
<div id="loading">
<div class="spinner"></div>
<h2>🤖 Hermes Agent Terminal</h2>
<p id="loading-msg">Connecting to terminal...</p>
<div id="loading-debug" class="debug"></div>
</div>
<div id="terminal-container"></div>
<div id="reconnect-overlay">
<p style="font-size:16px;margin-bottom:4px">⚡ Connection Lost</p>
<p style="font-size:13px;color:#565f89">Terminal session ended or connection interrupted</p>
<button onclick="location.reload()">🔄 Restart</button>
</div>
<link rel="stylesheet" href="/lib/xterm.min.css">
<script src="/lib/xterm.min.js"></script>
<script src="/lib/addon-fit.min.js"></script>
<script src="/lib/addon-web-links.min.js"></script>
<script>
(function() {
const statusEl = document.getElementById('status');
const loadingEl = document.getElementById('loading');
const reconnectEl = document.getElementById('reconnect-overlay');
const containerEl = document.getElementById('terminal-container');
const loadingMsg = document.getElementById('loading-msg');
const loadingDebug = document.getElementById('loading-debug');
const term = new window.Terminal({
cursorBlink: true,
cursorStyle: 'bar',
fontSize: 13,
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", "Source Code Pro", Menlo, Monaco, "Courier New", monospace',
lineHeight: 1.2,
scrollback: 5000,
allowProposedApi: true,
theme: {
background: '#1a1b26',
foreground: '#c0caf5',
cursor: '#ff9e64',
cursorAccent: '#1a1b26',
selectionBackground: '#33467c',
selectionForeground: '#c0caf5',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5',
}
});
const fitAddon = new window.FitAddon.FitAddon();
term.loadAddon(fitAddon);
try {
const webLinksAddon = new window.WebLinksAddon.WebLinksAddon();
term.loadAddon(webLinksAddon);
} catch(e) {}
term.open(containerEl);
containerEl.addEventListener('click', function() { term.focus(); });
document.addEventListener('click', function() { term.focus(); });
function doFit() {
try { fitAddon.fit(); } catch(e) {}
try { term.scrollToBottom(); } catch(e) {}
}
doFit();
window.addEventListener('resize', () => { setTimeout(doFit, 100); });
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const urlParams = new URLSearchParams(location.search);
const ptyToken = urlParams.get('token') || urlParams.get('pty_token') || '';
const wsUrl = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=' + encodeURIComponent(ptyToken) : '');
let ws = null;
let connected = false;
let retryCount = 0;
const MAX_RETRY = Infinity;
let retryTimer = null;
let wasEverConnected = false;
let pingTimer = null;
var _readyToConnect = false;
function _tryConnect() {
if (_readyToConnect) return;
try { fitAddon.fit(); } catch(e) {}
if (term.cols > 40) {
_readyToConnect = true;
connect();
}
}
if (typeof ResizeObserver !== 'undefined') {
new ResizeObserver(function() {
setTimeout(function() { doFit(); _tryConnect(); }, 50);
}).observe(containerEl);
}
function connect() {
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
if (ws) { try { ws.close(); } catch(e){} ws = null; }
retryCount++;
loadingMsg.textContent = retryCount > 1
? 'Reconnecting... (attempt ' + retryCount + ')'
: 'Connecting to terminal...';
loadingDebug.textContent = wsUrl;
fetch('/health').then(function(r) {
return r.json();
}).then(function(data) {
doWebSocket();
}).catch(function(err) {
scheduleRetry();
});
}
function doWebSocket() {
try {
ws = new WebSocket(wsUrl);
} catch(e) {
scheduleRetry();
return;
}
var connectTimeout = setTimeout(function() {
if (ws && ws.readyState !== WebSocket.OPEN) {
try { ws.close(); } catch(e){}
scheduleRetry();
}
}, 5000);
ws.onopen = function() {
clearTimeout(connectTimeout);
connected = true;
var isReconnect = wasEverConnected;
wasEverConnected = true;
retryCount = 0;
loadingEl.classList.add('hidden');
reconnectEl.classList.remove('show');
statusEl.textContent = '● Connected';
statusEl.className = 'status connected';
if (isReconnect) {
term.clear();
term.write('\x1b[33m⚡ Reconnected\x1b[0m\r\n\r\n');
}
setTimeout(doFit, 50);
term.focus();
try {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
} catch(e) {}
if (pingTimer) clearInterval(pingTimer);
pingTimer = setInterval(function() {
if (ws && ws.readyState === WebSocket.OPEN) {
try { ws.send(JSON.stringify({ type: 'ping' })); } catch(e) {}
}
}, 20000);
};
ws.onmessage = function(ev) {
if (typeof ev.data === 'string') {
if (ev.data.indexOf('"type":"pong"') !== -1) return;
term.write(ev.data, function() { term.scrollToBottom(); });
} else if (ev.data instanceof Blob) {
ev.data.text().then(function(text) {
if (text.indexOf('"type":"pong"') !== -1) return;
term.write(text, function() { term.scrollToBottom(); });
});
}
};
ws.onclose = function(ev) {
clearTimeout(connectTimeout);
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
connected = false;
statusEl.textContent = '● Disconnected';
statusEl.className = 'status disconnected';
if (wasEverConnected) {
statusEl.textContent = '● Reconnecting...';
retryCount = 0;
setTimeout(function() { connect(); }, 2000);
} else {
scheduleRetry();
}
};
ws.onerror = function(ev) {
clearTimeout(connectTimeout);
connected = false;
};
}
function scheduleRetry() {
var delay = Math.min(Math.floor(retryCount / 2) + 1, 5) * 1000;
loadingMsg.textContent = 'Waiting for service... retrying in ' + Math.ceil(delay/1000) + 's';
retryTimer = setTimeout(connect, delay);
}
term.onData(function(data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'stdin', data: data }));
}
});
term.onResize(function(size) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
}
});
function _onLoad() {
doFit();
_tryConnect();
}
if (document.readyState === 'complete') {
_onLoad();
} else {
window.addEventListener('load', _onLoad);
}
})();
</script>
</body>
</html>
+549
View File
@@ -0,0 +1,549 @@
#!/usr/bin/env python3
"""
luci-app-hermes — Web PTY Server (Python implementation)
Provides WebSocket-based terminal access for Hermes Agent configuration.
Replaces the Node.js web-pty.js from luci-app-openclaw.
"""
import http.server
import socketserver
import json
import os
import sys
import signal
import struct
import hashlib
import base64
import subprocess
import threading
import time
import select
import pty
import fcntl
import termios
import urllib.parse
# ── Configuration ──
PORT = int(os.environ.get('HERMES_PTY_PORT', '3001'))
HOST = os.environ.get('HERMES_PTY_HOST', '0.0.0.0')
AUTH_TOKEN = os.environ.get('HERMES_PTY_TOKEN', '')
HERMES_VENV = os.environ.get('HERMES_VENV', '/opt/hermes/venv')
HERMES_DATA = os.environ.get('HERMES_DATA', '/opt/hermes/data')
MAX_SESSIONS = int(os.environ.get('HERMES_MAX_SESSIONS', '5'))
UI_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'ui')
# Load token from UCI if not set via env
if not AUTH_TOKEN:
try:
result = subprocess.run(
['uci', '-q', 'get', 'hermes.main.pty_token'],
capture_output=True, text=True, timeout=3
)
AUTH_TOKEN = result.stdout.strip()
except Exception:
pass
# ── WebSocket helpers (RFC 6455) ──
def decode_ws_frame(data):
"""Decode a WebSocket frame. Returns (opcode, payload, total_consumed) or None."""
if len(data) < 2:
return None
opcode = data[0] & 0x0f
masked = bool(data[1] & 0x80)
payload_len = data[1] & 0x7f
offset = 2
if payload_len == 126:
if len(data) < 4:
return None
payload_len = struct.unpack('>H', data[2:4])[0]
offset = 4
elif payload_len == 127:
if len(data) < 10:
return None
payload_len = struct.unpack('>Q', data[2:10])[0]
offset = 10
mask = None
if masked:
if len(data) < offset + 4:
return None
mask = data[offset:offset + 4]
offset += 4
if len(data) < offset + payload_len:
return None
payload = bytearray(data[offset:offset + payload_len])
if mask:
for i in range(len(payload)):
payload[i] ^= mask[i & 3]
return (opcode, bytes(payload), offset + payload_len)
def encode_ws_frame(data, opcode=0x01):
"""Encode data into a WebSocket frame."""
if isinstance(data, str):
payload = data.encode('utf-8')
else:
payload = data
length = len(payload)
if length < 126:
header = struct.pack('>BB', 0x80 | opcode, length)
elif length < 65536:
header = struct.pack('>BBH', 0x80 | opcode, 126, length)
else:
header = struct.pack('>BBQ', 0x80 | opcode, 127, length)
return header + payload
# ── PTY Session ──
active_sessions = 0
class PtySession:
def __init__(self, request_handler):
global active_sessions
self.handler = request_handler
self.proc = None
self.cols = 80
self.rows = 24
self.alive = True
self.buffer = b''
self._spawn_fail_count = 0
self._MAX_SPAWN_RETRIES = 5
self._ping_timer = None
self._pong_received = True
active_sessions += 1
print(f"[hermes-pty] Session created (active: {active_sessions}/{MAX_SESSIONS})", flush=True)
self._spawn_pty()
def _spawn_pty(self):
"""Spawn the shell process with a PTY."""
env = os.environ.copy()
env.update({
'TERM': 'xterm-256color',
'COLUMNS': str(self.cols),
'LINES': str(self.rows),
'COLORTERM': 'truecolor',
'LANG': 'en_US.UTF-8',
'HERMES_VENV': HERMES_VENV,
'HERMES_DATA': HERMES_DATA,
'HOME': '/root',
'PATH': f"{HERMES_VENV}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
})
try:
# Create PTY
master_fd, slave_fd = pty.openpty()
# Set terminal size
winsize = struct.pack('HHHH', self.rows, self.cols, 0, 0)
fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, winsize)
# Spawn shell
self.proc = subprocess.Popen(
['/bin/sh', '-l'],
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
env=env,
preexec_fn=os.setsid,
)
os.close(slave_fd)
self.master_fd = master_fd
self._spawn_fail_count = 0
# Set master_fd to non-blocking
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
# Start reader thread
self._reader_thread = threading.Thread(target=self._read_pty, daemon=True)
self._reader_thread.start()
print(f"[hermes-pty] Shell spawned (PID: {self.proc.pid})", flush=True)
except Exception as e:
print(f"[hermes-pty] Failed to spawn shell: {e}", flush=True)
self._cleanup()
def _read_pty(self):
"""Read from PTY and send to WebSocket."""
while self.alive and self.proc and self.proc.poll() is None:
try:
r, _, _ = select.select([self.master_fd], [], [], 0.1)
if r:
data = os.read(self.master_fd, 4096)
if data:
self._send_to_client(data)
except (OSError, IOError):
break
except Exception as e:
if self.alive:
print(f"[hermes-pty] Read error: {e}", flush=True)
break
if self.alive:
print(f"[hermes-pty] Shell exited", flush=True)
self._spawn_fail_count += 1
if self._spawn_fail_count > self._MAX_SPAWN_RETRIES:
print(f"[hermes-pty] Shell failed {self._spawn_fail_count} times, stopping", flush=True)
self._send_to_client(b'\r\n\x1b[31mShell failed to start after multiple attempts.\x1b[0m\r\n')
return
# Auto-restart
self._send_to_client(b'\r\n\x1b[33mShell exited, restarting...\x1b[0m\r\n')
time.sleep(1)
if self.alive:
self._spawn_pty()
def _send_to_client(self, data):
"""Send data to WebSocket client."""
if not self.alive:
return
try:
frame = encode_ws_frame(data, 0x01)
self.handler.request.sendall(frame)
except Exception:
pass
def handle_message(self, text):
"""Handle incoming WebSocket text message."""
try:
msg = json.loads(text)
msg_type = msg.get('type', '')
if msg_type == 'stdin':
data = msg.get('data', '')
# Clean bracketed paste escape sequences
data = data.replace('\x1b[?2004l', '').replace('\x1b[?2004h', '')
data = data.replace('\x1b[200~', '').replace('\x1b[201~', '')
if self.proc and self.proc.poll() is None:
os.write(self.master_fd, data.encode('utf-8'))
elif msg_type == 'resize':
self.cols = msg.get('cols', 80)
self.rows = msg.get('rows', 24)
if self.master_fd:
winsize = struct.pack('HHHH', self.rows, self.cols, 0, 0)
try:
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize)
except Exception:
pass
elif msg_type == 'ping':
self._send_to_client(json.dumps({'type': 'pong'}))
except json.JSONDecodeError:
# Plain text, send directly to PTY
if self.proc and self.proc.poll() is None:
os.write(self.master_fd, text.encode('utf-8'))
def handle_binary(self, data):
"""Handle incoming binary WebSocket data."""
if self.proc and self.proc.poll() is None:
os.write(self.master_fd, data)
def _cleanup(self):
"""Clean up session resources."""
if not self.alive:
return
self.alive = False
global active_sessions
active_sessions = max(0, active_sessions - 1)
print(f"[hermes-pty] Session ended (active: {active_sessions}/{MAX_SESSIONS})", flush=True)
if self.proc and self.proc.poll() is None:
try:
os.kill(-self.proc.pid, signal.SIGTERM)
except Exception:
pass
try:
self.proc.terminate()
except Exception:
pass
try:
if hasattr(self, 'master_fd'):
os.close(self.master_fd)
except Exception:
pass
def __del__(self):
self._cleanup()
# ── WebSocket Upgrade Handler ──
class WebSocketHandler(http.server.BaseHTTPRequestHandler):
"""HTTP + WebSocket upgrade handler."""
sessions = []
def log_message(self, format, *args):
"""Override to add prefix."""
print(f"[hermes-pty] {args[0] if args else format}", flush=True)
def do_OPTIONS(self):
self.send_response(204)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
self.send_header('Access-Control-Allow-Headers', '*')
self.end_headers()
def do_GET(self):
path = urllib.parse.urlparse(self.path).path
if path == '/health':
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps({
'status': 'ok',
'port': PORT,
'uptime': time.time()
}).encode())
return
# Serve static files from UI_DIR
if path == '/' or path == '':
path = '/index.html'
# Security: prevent directory traversal
full_path = os.path.normpath(os.path.join(UI_DIR, path.lstrip('/')))
if not full_path.startswith(UI_DIR):
self.send_response(403)
self.end_headers()
return
try:
with open(full_path, 'rb') as f:
data = f.read()
# Determine content type
ext = os.path.splitext(full_path)[1]
content_types = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css',
'.js': 'application/javascript',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.json': 'application/json',
}
ct = content_types.get(ext, 'application/octet-stream')
self.send_response(200)
self.send_header('Content-Type', ct)
self.send_header('Cache-Control', 'no-cache' if ext == '.html' else 'max-age=3600')
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('X-Frame-Options', 'ALLOWALL')
self.send_header('Content-Security-Policy',
"default-src * 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss:; frame-ancestors *")
self.end_headers()
self.wfile.write(data)
except FileNotFoundError:
# Fallback to index.html for SPA routing
try:
with open(os.path.join(UI_DIR, 'index.html'), 'rb') as f:
data = f.read()
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(data)
except FileNotFoundError:
self.send_response(404)
self.end_headers()
def do_GET_ws(self):
"""Handle WebSocket upgrade for GET requests to /ws."""
self._do_websocket_upgrade()
def _do_websocket_upgrade(self):
"""Perform WebSocket handshake and start PTY session."""
# Authenticate
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
client_token = params.get('token', [''])[0]
if AUTH_TOKEN and client_token != AUTH_TOKEN:
print(f"[hermes-pty] Auth failed from {self.client_address[0]}", flush=True)
self.send_error(403, 'Forbidden')
return
# Check session limit
if active_sessions >= MAX_SESSIONS:
print(f"[hermes-pty] Max sessions reached ({active_sessions}/{MAX_SESSIONS})", flush=True)
self.send_error(503, 'Service Unavailable')
return
# WebSocket handshake
key = self.headers.get('Sec-WebSocket-Key', '')
if not key:
self.send_error(400, 'Missing Sec-WebSocket-Key')
return
accept = base64.b64encode(
hashlib.sha1((key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()
).decode()
response = (
'HTTP/1.1 101 Switching Protocols\r\n'
'Upgrade: websocket\r\n'
'Connection: Upgrade\r\n'
f'Sec-WebSocket-Accept: {accept}\r\n'
'\r\n'
)
self.wfile.write(response.encode())
self.wfile.flush()
# Create PTY session
session = PtySession(self)
self.__class__.sessions.append(session)
# Read WebSocket frames
buffer = b''
while session.alive:
try:
self.request.setblocking(True)
self.request.settimeout(1.0)
data = self.request.recv(4096)
if not data:
break
buffer += data
while len(buffer) > 0:
result = decode_ws_frame(buffer)
if result is None:
break
opcode, payload, consumed = result
buffer = buffer[consumed:]
if opcode == 0x01: # Text
session.handle_message(payload.decode('utf-8', errors='replace'))
elif opcode == 0x02: # Binary
session.handle_binary(payload)
elif opcode == 0x08: # Close
print("[hermes-pty] WS close frame received", flush=True)
session._cleanup()
return
elif opcode == 0x09: # Ping
self.request.sendall(encode_ws_frame(payload, 0x0a))
elif opcode == 0x0a: # Pong
session._pong_received = True
except socket.timeout:
continue
except (ConnectionResetError, BrokenPipeError, OSError):
break
except Exception as e:
if session.alive:
print(f"[hermes-pty] Read error: {e}", flush=True)
break
session._cleanup()
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if path == '/ws' or path.startswith('/ws?'):
self._do_websocket_upgrade()
else:
# Serve static files
if path == '/' or path == '':
path = '/index.html'
full_path = os.path.normpath(os.path.join(UI_DIR, path.lstrip('/')))
if not full_path.startswith(UI_DIR):
self.send_response(403)
self.end_headers()
return
try:
with open(full_path, 'rb') as f:
data = f.read()
ext = os.path.splitext(full_path)[1]
content_types = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css',
'.js': 'application/javascript',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.json': 'application/json',
}
ct = content_types.get(ext, 'application/octet-stream')
self.send_response(200)
self.send_header('Content-Type', ct)
self.send_header('Cache-Control', 'no-cache' if ext == '.html' else 'max-age=3600')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(data)
except FileNotFoundError:
try:
with open(os.path.join(UI_DIR, 'index.html'), 'rb') as f:
data = f.read()
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(data)
except FileNotFoundError:
self.send_response(404)
self.end_headers()
# ── Main ──
if __name__ == '__main__':
import socket
socketserver.TCPServer.allow_reuse_address = True
class ReusableTCPServer(socketserver.TCPServer):
allow_reuse_address = True
def handle_request_noblock(self):
try:
request, client_address = self.get_request()
except OSError:
return
if self.verify_request(request, client_address):
try:
self.process_request(request, client_address)
except Exception:
self.handle_error(request, client_address)
self.shutdown_request(request)
else:
self.shutdown_request(request)
server = ReusableTCPServer((HOST, PORT), WebSocketHandler)
print(f"[hermes-pty] HTTP listening on {HOST}:{PORT}", flush=True)
print(f"[hermes-pty] UI directory: {UI_DIR}", flush=True)
def shutdown(signum, frame):
print("[hermes-pty] Shutdown", flush=True)
server.shutdown()
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
try:
server.serve_forever()
except KeyboardInterrupt:
shutdown(None, None)
@@ -0,0 +1,14 @@
{
"admin/services/hermes": {
"title": "Hermes Agent",
"order": 90,
"action": {
"type": "view",
"path": "hermes"
},
"depends": {
"acl": ["luci-app-hermes"],
"uci": { "hermes": true }
}
}
}
@@ -1,11 +1,29 @@
{
"luci-app-hermes": {
"description": "Grant UCI access for luci-app-hermes",
"description": "Grant access for luci-app-hermes",
"read": {
"uci": [ "hermes" ]
"uci": ["hermes"],
"ubus": {
"service": ["list"],
"file": ["exec", "read", "stat"]
},
"file": {
"/usr/share/hermes/luci-helper": ["exec"],
"/etc/init.d/hermes": ["exec"],
"/usr/share/hermes/VERSION": ["read"],
"/tmp/hermes-setup.log": ["read"],
"/tmp/hermes-setup.exit": ["read"]
}
},
"write": {
"uci": [ "hermes" ]
"uci": ["hermes"],
"ubus": {
"file": ["exec", "write"]
},
"file": {
"/usr/share/hermes/luci-helper": ["exec"],
"/etc/init.d/hermes": ["exec"]
}
}
}
}