release: v1.0.0 — LuCI 管理界面、一键安装、12+ AI 模型提供商

This commit is contained in:
10000ge10000
2026-03-02 16:23:52 +08:00
commit c1c3151a9f
28 changed files with 5260 additions and 0 deletions

6
root/etc/config/openclaw Normal file
View File

@@ -0,0 +1,6 @@
config openclaw 'main'
option enabled '0'
option port '18789'
option bind 'lan'
option token ''
option pty_port '18793'

330
root/etc/init.d/openclaw Executable file
View File

@@ -0,0 +1,330 @@
#!/bin/sh /etc/rc.common
# luci-app-openclaw — procd init 脚本
USE_PROCD=1
START=99
STOP=10
EXTRA_COMMANDS="setup status_service restart_gateway"
EXTRA_HELP=" setup 下载 Node.js 并安装 OpenClaw
status_service 显示服务状态
restart_gateway 仅重启 Gateway 实例 (不影响 Web PTY)"
NODE_BASE="/opt/openclaw/node"
OC_GLOBAL="/opt/openclaw/global"
OC_DATA="/opt/openclaw/data"
NODE_BIN="${NODE_BASE}/bin/node"
CONFIG_FILE="${OC_DATA}/.openclaw/openclaw.json"
get_oc_entry() {
local search_dirs="${OC_GLOBAL}/lib/node_modules/openclaw
${OC_GLOBAL}/node_modules/openclaw
${NODE_BASE}/lib/node_modules/openclaw"
# pnpm 全局安装路径形如: $OC_GLOBAL/5/node_modules/openclaw
for ver_dir in "${OC_GLOBAL}"/*/node_modules/openclaw; do
[ -d "$ver_dir" ] && search_dirs="$search_dirs
$ver_dir"
done
local d
echo "$search_dirs" | while read -r d; do
[ -z "$d" ] && continue
if [ -f "${d}/openclaw.mjs" ]; then
echo "${d}/openclaw.mjs"
return
elif [ -f "${d}/dist/cli.js" ]; then
echo "${d}/dist/cli.js"
return
fi
done
}
patch_iframe_headers() {
# 移除 OpenClaw 网关的 X-Frame-Options 和 frame-ancestors 限制,允许 LuCI iframe 嵌入
local gw_js
for f in $(find "${OC_GLOBAL}" -name "gateway-cli-*.js" -type f 2>/dev/null); do
if grep -q "X-Frame-Options.*DENY" "$f" 2>/dev/null; then
sed -i "s|res.setHeader(\"X-Frame-Options\", \"DENY\");|// res.setHeader(\"X-Frame-Options\", \"DENY\"); // patched by luci-app-openclaw|g" "$f"
sed -i "s|\"frame-ancestors 'none'\"|\"frame-ancestors *\"|g" "$f"
logger -t openclaw "Patched iframe headers in $f"
fi
done
}
sync_uci_to_json() {
# 将 UCI 配置同步到 openclaw.json同时确保 token 双向同步
local port bind token
port=$(uci -q get openclaw.main.port || echo "18789")
bind=$(uci -q get openclaw.main.bind || echo "lan")
token=$(uci -q get openclaw.main.token || echo "")
# 确保配置目录和文件存在
mkdir -p "$(dirname "$CONFIG_FILE")"
if [ ! -f "$CONFIG_FILE" ]; then
echo '{}' > "$CONFIG_FILE"
fi
# UCI 没有 token 时,尝试从已有的 JSON 读取
if [ -z "$token" ] && [ -x "$NODE_BIN" ]; then
token=$("$NODE_BIN" -e "
try{const d=JSON.parse(require('fs').readFileSync('${CONFIG_FILE}','utf8'));
if(d.gateway&&d.gateway.auth&&d.gateway.auth.token)process.stdout.write(d.gateway.auth.token);}catch(e){}
" 2>/dev/null)
fi
# 如果仍然没有 token生成一个新的
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 "auto_$(date +%s)")
fi
# 确保 token 写回 UCI
local uci_token
uci_token=$(uci -q get openclaw.main.token || echo "")
if [ "$uci_token" != "$token" ]; then
uci set openclaw.main.token="$token"
uci commit openclaw 2>/dev/null
fi
# 使用 Node.js 写入 JSON (如果可用)
if [ -x "$NODE_BIN" ]; then
OC_SYNC_PORT="$port" OC_SYNC_BIND="$bind" OC_SYNC_TOKEN="$token" OC_SYNC_FILE="$CONFIG_FILE" \
"$NODE_BIN" -e "
const fs=require('fs');
const f=process.env.OC_SYNC_FILE;
let d={};
try{d=JSON.parse(fs.readFileSync(f,'utf8'));}catch(e){}
if(!d.gateway)d.gateway={};
d.gateway.port=parseInt(process.env.OC_SYNC_PORT)||18789;
d.gateway.bind=process.env.OC_SYNC_BIND||'lan';
d.gateway.mode='local';
if(!d.gateway.auth)d.gateway.auth={};
d.gateway.auth.mode='token';
d.gateway.auth.token=process.env.OC_SYNC_TOKEN||'';
if(!d.gateway.controlUi)d.gateway.controlUi={};
d.gateway.controlUi.allowInsecureAuth=true;
d.gateway.controlUi.dangerouslyDisableDeviceAuth=true;
d.gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true;
// 清理 v2026.3.1+ 已废弃的字段,避免配置验证失败
delete d.gateway.name;
delete d.gateway.bonjour;
delete d.gateway.plugins;
fs.writeFileSync(f,JSON.stringify(d,null,2));
" 2>/dev/null
fi
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
}
start_service() {
local enabled port bind pty_port
config_load openclaw
config_get enabled main enabled "0"
config_get port main port "18789"
config_get bind main bind "lan"
config_get pty_port main pty_port "18793"
[ "$enabled" = "1" ] || {
echo "openclaw 已禁用。请在 /etc/config/openclaw 中设置 enabled 为 1"
return 0
}
# 检查 Node.js
if [ ! -x "$NODE_BIN" ]; then
echo "未找到 Node.js: $NODE_BIN"
echo "请运行: openclaw-env setup"
return 1
fi
# 检查 openclaw 入口
local oc_entry
oc_entry=$(get_oc_entry)
if [ -z "$oc_entry" ]; then
echo "OpenClaw 未安装。请运行: openclaw-env setup"
return 1
fi
# 同步 UCI 到 JSON
sync_uci_to_json
# 修复数据目录权限 (防止 root 用户操作后留下无法读取的文件)
chown -R openclaw:openclaw "$OC_DATA" 2>/dev/null || true
# Patch iframe 安全头,允许 LuCI 嵌入
patch_iframe_headers
# 将 UCI bind 映射到 openclaw gateway --bind 参数
local gw_bind="loopback"
case "$bind" in
lan) gw_bind="lan" ;;
loopback) gw_bind="loopback" ;;
all) gw_bind="custom" ;; # custom = 0.0.0.0
*) gw_bind="$bind" ;;
esac
# 启动 OpenClaw Gateway (主服务, 前台运行)
procd_open_instance "gateway"
procd_set_param command "$NODE_BIN" "$oc_entry" gateway run \
--port "$port" --bind "$gw_bind"
procd_set_param env \
HOME="$OC_DATA" \
OPENCLAW_HOME="$OC_DATA" \
OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw" \
OPENCLAW_CONFIG_PATH="$CONFIG_FILE" \
NODE_BASE="$NODE_BASE" \
OC_GLOBAL="$OC_GLOBAL" \
OC_DATA="$OC_DATA" \
PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
procd_set_param user openclaw
procd_set_param respawn 3600 5 0
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param pidfile /var/run/openclaw.pid
procd_close_instance
# 启动 Web PTY 配置终端 (辅助服务)
# 生成 PTY 会话 token 用于 WebSocket 认证
local pty_token
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 openclaw.main.pty_token="$pty_token"
uci commit openclaw 2>/dev/null
procd_open_instance "pty"
procd_set_param command "$NODE_BIN" /usr/share/openclaw/web-pty.js
procd_set_param env \
OC_CONFIG_PORT="$pty_port" \
OC_PTY_TOKEN="$pty_token" \
OC_CONFIG_SCRIPT="/usr/share/openclaw/oc-config.sh" \
NODE_BASE="$NODE_BASE" \
OC_GLOBAL="$OC_GLOBAL" \
OC_DATA="$OC_DATA" \
HOME="$OC_DATA" \
OPENCLAW_HOME="$OC_DATA" \
OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw" \
OPENCLAW_CONFIG_PATH="$CONFIG_FILE" \
PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param pidfile /var/run/openclaw-pty.pid
procd_close_instance
}
stop_service() {
# procd 会自动处理进程停止
return 0
}
service_triggers() {
procd_add_reload_trigger "openclaw"
}
reload_service() {
stop
start
}
setup() {
echo "正在调用 openclaw-env setup..."
/usr/bin/openclaw-env setup
}
restart_gateway() {
# 仅重启 Gateway procd 实例,不影响 Web PTY
# 使用 stop+start 而非 kill可重置 procd crash loop 计数器
local port
port=$(uci -q get openclaw.main.port || echo "18789")
# 停止 gateway 实例 (通过 ubus不影响 PTY 实例)
ubus call service stop '{"name":"openclaw","instance":"gateway"}' 2>/dev/null || true
# 等待端口释放
local i=0
while [ $i -lt 8 ]; do
netstat -tln 2>/dev/null | grep -q ":${port} " || break
sleep 1; i=$((i+1))
done
# 重新启动整个服务 (procd 会重建 gateway 实例,同时保留 PTY 实例)
# procd 的 start_service 幂等:已运行的实例不会重复启动
/etc/init.d/openclaw start >/dev/null 2>&1
}
status_service() {
local port pty_port
port=$(uci -q get openclaw.main.port || echo "18789")
pty_port=$(uci -q get openclaw.main.pty_port || echo "18793")
echo "=== OpenClaw 服务状态 ==="
# Node.js
if [ -x "$NODE_BIN" ]; then
echo "Node.js: $($NODE_BIN --version 2>/dev/null)"
else
echo "Node.js: 未安装"
fi
# OpenClaw
local oc_entry
oc_entry=$(get_oc_entry)
if [ -n "$oc_entry" ]; then
local ver
ver=$("$NODE_BIN" "$oc_entry" --version 2>/dev/null | tr -d '[:space:]')
echo "OpenClaw: v${ver:-未知}"
else
echo "OpenClaw: 未安装"
fi
# 端口检测函数 (ss 优先, 回退 netstat)
_check_port() {
local p="$1"
if command -v ss >/dev/null 2>&1; then
ss -tlnp 2>/dev/null | grep -q ":${p} "
else
netstat -tlnp 2>/dev/null | grep -q ":${p} "
fi
}
_get_pid_by_port() {
local p="$1"
if command -v ss >/dev/null 2>&1; then
ss -tlnp 2>/dev/null | grep ":${p} " | head -1 | sed -n 's/.*pid=\([0-9]*\).*/\1/p'
else
netstat -tlnp 2>/dev/null | grep ":${p} " | head -1 | sed 's|.* \([0-9]*\)/.*|\1|'
fi
}
# Gateway port
if _check_port "$port"; then
echo "网关: 运行中 (端口 $port)"
# PID
local pid
pid=$(_get_pid_by_port "$port")
if [ -n "$pid" ]; then
echo "进程ID: $pid"
# Memory
local rss
rss=$(awk '/VmRSS/{print $2}' /proc/$pid/status 2>/dev/null)
[ -n "$rss" ] && echo "内存: ${rss} kB"
# Uptime
local start_time now_time
start_time=$(stat -c %Y /proc/$pid 2>/dev/null || echo "0")
now_time=$(date +%s)
if [ "$start_time" -gt 0 ]; then
local uptime=$((now_time - start_time))
local hours=$((uptime / 3600))
local mins=$(( (uptime % 3600) / 60 ))
echo "运行时间: ${hours}小时 ${mins}分钟"
fi
fi
else
echo "网关: 已停止"
fi
# PTY port
if _check_port "$pty_port"; then
echo "Web PTY: 运行中 (端口 $pty_port)"
else
echo "Web PTY: 已停止"
fi
}

View File

@@ -0,0 +1,41 @@
#!/bin/sh
# luci-app-openclaw — 首次安装初始化脚本
# 创建 openclaw 系统用户 (无 home, 无 shell)
if ! id openclaw >/dev/null 2>&1; then
# 动态查找可用 UID/GID (从 1000 开始,避免与已有用户冲突)
OC_UID=1000
while grep -q "^[^:]*:x:${OC_UID}:" /etc/passwd 2>/dev/null; do
OC_UID=$((OC_UID + 1))
done
OC_GID=$OC_UID
while grep -q "^[^:]*:x:${OC_GID}:" /etc/group 2>/dev/null; do
OC_GID=$((OC_GID + 1))
done
# OpenWrt 方式:直接写入 /etc/passwd 和 /etc/shadow
if ! grep -q '^openclaw:' /etc/passwd 2>/dev/null; then
echo "openclaw:x:${OC_UID}:${OC_GID}:openclaw:/opt/openclaw/data:/bin/false" >> /etc/passwd
fi
if ! grep -q '^openclaw:' /etc/shadow 2>/dev/null; then
echo 'openclaw:x:0:0:99999:7:::' >> /etc/shadow
fi
if ! grep -q '^openclaw:' /etc/group 2>/dev/null; then
echo "openclaw:x:${OC_GID}:" >> /etc/group
fi
fi
# 创建数据目录
mkdir -p /opt/openclaw/data/.openclaw
mkdir -p /opt/openclaw/node
mkdir -p /opt/openclaw/global
chown -R openclaw:openclaw /opt/openclaw 2>/dev/null || true
# 生成随机 Token (如果尚未设置)
CURRENT_TOKEN=$(uci -q get openclaw.main.token)
if [ -z "$CURRENT_TOKEN" ]; then
TOKEN=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || openssl rand -hex 24 2>/dev/null || echo "changeme_$(date +%s)")
uci set openclaw.main.token="$TOKEN"
uci commit openclaw
fi
exit 0

478
root/usr/bin/openclaw-env Executable file
View File

@@ -0,0 +1,478 @@
#!/bin/sh
# ============================================================================
# openclaw-env — Node.js 环境自动检测/下载 + OpenClaw 安装
# 用法:
# openclaw-env setup — 完整安装 (Node.js + pnpm + OpenClaw)
# openclaw-env check — 检查环境状态
# openclaw-env upgrade — 升级 OpenClaw 到最新版
# openclaw-env node — 仅下载/更新 Node.js
# 环境变量:
# OC_VERSION — 指定 OpenClaw 版本 (如 2026.3.1),不设置则安装最新版
# ============================================================================
set -e
NODE_VERSION="${NODE_VERSION:-22.16.0}"
# 经过验证的 OpenClaw 稳定版本 (更新此值需同步测试)
OC_TESTED_VERSION="2026.3.1"
# 用户可通过 OC_VERSION 环境变量覆盖安装版本
OC_VERSION="${OC_VERSION:-}"
NODE_BASE="/opt/openclaw/node"
OC_GLOBAL="/opt/openclaw/global"
OC_DATA="/opt/openclaw/data"
NODE_BIN="${NODE_BASE}/bin/node"
NPM_BIN="${NODE_BASE}/bin/npm"
PNPM_BIN="${OC_GLOBAL}/bin/pnpm"
# Node.js 官方镜像 + musl 非官方构建
NODE_MIRROR="${NODE_MIRROR:-https://nodejs.org/dist}"
NODE_MIRROR_CN="https://npmmirror.com/mirrors/node"
NODE_MUSL_MIRROR="https://unofficial-builds.nodejs.org/download/release"
export PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:$PATH"
log_info() { echo " [✓] $1"; }
log_warn() { echo " [!] $1"; }
log_error() { echo " [✗] $1"; }
# 检测 C 运行时类型 (glibc vs musl)
detect_libc() {
if ldd --version 2>&1 | grep -qi musl; then
echo "musl"
elif [ -f /lib/ld-musl-*.so.1 ] 2>/dev/null; then
echo "musl"
elif [ -f /etc/openwrt_release ] || grep -qi "openwrt\|istoreos\|lede" /etc/os-release 2>/dev/null; then
echo "musl"
else
echo "glibc"
fi
}
# 在所有可能的位置查找 openclaw 入口文件
# pnpm 全局安装路径形如: $OC_GLOBAL/5/node_modules/openclaw
find_oc_entry() {
local search_dirs="${OC_GLOBAL}/lib/node_modules/openclaw
${OC_GLOBAL}/node_modules/openclaw
${NODE_BASE}/lib/node_modules/openclaw"
# 添加 pnpm 版本化目录 (如 /opt/openclaw/global/5/node_modules/openclaw)
for ver_dir in "${OC_GLOBAL}"/*/node_modules/openclaw; do
[ -d "$ver_dir" ] 2>/dev/null && search_dirs="$search_dirs
$ver_dir"
done
local d
echo "$search_dirs" | while read -r d; do
[ -z "$d" ] && continue
if [ -f "${d}/openclaw.mjs" ]; then
echo "${d}/openclaw.mjs"
return
elif [ -f "${d}/dist/cli.js" ]; then
echo "${d}/dist/cli.js"
return
fi
done
}
detect_arch() {
local arch
arch=$(uname -m)
case "$arch" in
x86_64) echo "linux-x64" ;;
aarch64) echo "linux-arm64" ;;
armv7l|armv6l)
log_error "不支持的 CPU 架构: $arch"
log_error "Node.js v22+ 不提供 32 位 ARM 预编译包。"
log_error "建议: 使用 aarch64 (ARM64) 版本的固件,或使用 x86_64 设备。"
exit 1
;;
*)
log_error "不支持的 CPU 架构: $arch (仅支持 x86_64/aarch64)"
exit 1
;;
esac
}
download_node() {
local node_ver="$1"
local node_arch
node_arch=$(detect_arch)
local libc_type
libc_type=$(detect_libc)
local tarball=""
local url="" url_fallback=""
if [ "$libc_type" = "musl" ]; then
tarball="node-v${node_ver}-${node_arch}-musl.tar.xz"
url="${NODE_MUSL_MIRROR}/v${node_ver}/${tarball}"
url_fallback=""
echo ""
echo "=== 下载 Node.js v${node_ver} (${node_arch}, musl libc) ==="
else
tarball="node-v${node_ver}-${node_arch}.tar.xz"
url="${NODE_MIRROR}/v${node_ver}/${tarball}"
url_fallback="${NODE_MIRROR_CN}/v${node_ver}/${tarball}"
echo ""
echo "=== 下载 Node.js v${node_ver} (${node_arch}, glibc) ==="
fi
local tmp_file="/tmp/${tarball}"
# 如果已存在且版本正确, 跳过
if [ -x "$NODE_BIN" ]; then
local current_ver
current_ver=$("$NODE_BIN" --version 2>/dev/null | sed 's/^v//')
if [ "$current_ver" = "$node_ver" ]; then
log_info "Node.js v${node_ver} 已安装, 跳过下载"
return 0
fi
log_warn "当前 Node.js v${current_ver}, 将更新到 v${node_ver}"
fi
# 下载 (带重试)
local downloaded=0
local mirror_list="$url"
[ -n "$url_fallback" ] && mirror_list="$url $url_fallback"
for mirror_url in $mirror_list; do
echo " 正在从 ${mirror_url} 下载..."
if curl -fSL --connect-timeout 15 --max-time 300 -o "$tmp_file" "$mirror_url" 2>/dev/null || \
wget -q --timeout=15 -O "$tmp_file" "$mirror_url" 2>/dev/null; then
downloaded=1
break
fi
log_warn "下载失败, 尝试备用镜像..."
done
if [ "$downloaded" -eq 0 ]; then
log_error "所有镜像均下载失败"
rm -f "$tmp_file"
exit 1
fi
# 解压
echo " 正在解压到 ${NODE_BASE}..."
rm -rf "$NODE_BASE"
mkdir -p "$NODE_BASE"
tar xf "$tmp_file" -C "$NODE_BASE" --strip-components=1
rm -f "$tmp_file"
# 验证
if [ -x "$NODE_BIN" ]; then
log_info "Node.js $($NODE_BIN --version) 安装成功"
else
log_error "Node.js 安装验证失败"
exit 1
fi
}
install_pnpm() {
echo ""
echo "=== 安装 pnpm ==="
if [ -x "$PNPM_BIN" ]; then
log_info "pnpm 已安装: $($PNPM_BIN --version 2>/dev/null)"
return 0
fi
# 使用 npm 安装 pnpm 到全局目录
mkdir -p "$OC_GLOBAL"
"$NPM_BIN" install -g pnpm --prefix="$OC_GLOBAL" 2>/dev/null
if [ -x "$OC_GLOBAL/bin/pnpm" ]; then
PNPM_BIN="$OC_GLOBAL/bin/pnpm"
log_info "pnpm $($PNPM_BIN --version 2>/dev/null) 安装成功"
elif [ -x "$NODE_BASE/bin/pnpm" ]; then
PNPM_BIN="$NODE_BASE/bin/pnpm"
log_info "pnpm $($PNPM_BIN --version 2>/dev/null) 安装成功"
else
log_warn "pnpm 安装失败, 将使用 npm 作为回退"
fi
}
install_openclaw() {
echo ""
echo "=== 安装 OpenClaw ==="
# 确定安装版本
local oc_pkg="openclaw"
if [ -n "$OC_VERSION" ]; then
oc_pkg="openclaw@${OC_VERSION}"
log_info "指定版本: v${OC_VERSION}"
else
oc_pkg="openclaw@latest"
log_info "安装最新版本"
fi
local libc_type
libc_type=$(detect_libc)
# musl 系统使用 npm + --ignore-scripts 避免 node-llama-cpp 编译失败
# glibc 系统正常安装
local install_flags=""
if [ "$libc_type" = "musl" ]; then
log_warn "检测到 musl libc将跳过本地编译依赖 (不影响核心功能)"
install_flags="--ignore-scripts"
fi
# 检查 git 是否可用 (openclaw 部分依赖可能使用 git:// 协议)
if ! command -v git >/dev/null 2>&1; then
log_warn "未检测到 git正在尝试安装..."
opkg update >/dev/null 2>&1
opkg install git git-http 2>&1 | tail -3 || true
if command -v git >/dev/null 2>&1; then
log_info "git 安装成功"
else
log_warn "git 安装失败,将尝试无 git 模式安装"
fi
fi
# 优先用 npm 安装 (pnpm 在 musl 上全局安装可能有路径问题)
local npm_ok=0
if [ -x "$NPM_BIN" ]; then
mkdir -p "$OC_GLOBAL"
"$NPM_BIN" install -g "$oc_pkg" --prefix="$OC_GLOBAL" $install_flags 2>&1 | tail -10
# 检查是否安装成功
if [ -n "$(find_oc_entry)" ]; then
npm_ok=1
else
log_warn "首次安装未成功,尝试 --no-optional 模式重试..."
"$NPM_BIN" install -g "$oc_pkg" --prefix="$OC_GLOBAL" $install_flags --no-optional 2>&1 | tail -10
[ -n "$(find_oc_entry)" ] && npm_ok=1
fi
elif [ -x "$PNPM_BIN" ]; then
mkdir -p "$OC_GLOBAL"
"$PNPM_BIN" install -g "$oc_pkg" --prefix="$OC_GLOBAL" 2>&1 | tail -5
else
log_error "npm 和 pnpm 均不可用"
exit 1
fi
# 验证
local oc_ver=""
local oc_found
oc_found=$(find_oc_entry)
if [ -n "$oc_found" ]; then
oc_ver=$("$NODE_BIN" "$oc_found" --version 2>/dev/null | tr -d '[:space:]')
fi
if [ -n "$oc_ver" ]; then
log_info "OpenClaw v${oc_ver} 安装成功"
else
log_error "OpenClaw 安装验证失败"
exit 1
fi
}
init_openclaw() {
echo ""
echo "=== 初始化 OpenClaw ==="
# 创建数据目录
mkdir -p "$OC_DATA/.openclaw"
# 运行 onboard
local oc_entry=""
oc_entry=$(find_oc_entry)
if [ -n "$oc_entry" ]; then
HOME="$OC_DATA" \
OPENCLAW_HOME="$OC_DATA" \
OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw" \
OPENCLAW_CONFIG_PATH="${OC_DATA}/.openclaw/openclaw.json" \
"$NODE_BIN" "$oc_entry" onboard --non-interactive --accept-risk 2>/dev/null || true
log_info "初始化完成"
fi
# 设置文件权限
chown -R openclaw:openclaw "$OC_DATA" 2>/dev/null || true
chown -R openclaw:openclaw "$OC_GLOBAL" 2>/dev/null || true
chown -R openclaw:openclaw "$NODE_BASE" 2>/dev/null || true
}
do_setup() {
local node_ver="$NODE_VERSION"
# 检查是否已安装
if [ -x "$NODE_BIN" ] && [ -n "$(find_oc_entry)" ]; then
local oc_ver=""
local oc_entry="$(find_oc_entry)"
local oc_pkg_dir="$(dirname "$oc_entry")"
[ -f "$oc_pkg_dir/package.json" ] && \
oc_ver=$("$NODE_BIN" -e "try{console.log(require('$oc_pkg_dir/package.json').version)}catch(e){}" 2>/dev/null) || true
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ ⚠️ OpenClaw 运行环境已安装,无需重复安装 ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo " Node.js: $($NODE_BIN --version 2>/dev/null)"
[ -n "$oc_ver" ] && echo " OpenClaw: v${oc_ver}"
echo ""
echo " 如需升级,请使用: openclaw-env upgrade"
echo " 如需重装,请先卸载: 在 LuCI 界面点击「卸载环境」"
echo ""
exit 0
fi
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ 一万AI分享 OpenClaw 环境安装 ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo " 架构: $(uname -m)"
echo " Node 版本: v${node_ver}"
if [ -n "$OC_VERSION" ]; then
echo " OpenClaw: v${OC_VERSION} (稳定版)"
else
echo " OpenClaw: 最新版"
fi
echo " 安装路径: ${NODE_BASE}"
echo " 数据路径: ${OC_DATA}"
echo ""
download_node "$node_ver"
install_pnpm
install_openclaw
init_openclaw
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ ✅ 安装完成! ║"
echo "║ ║"
echo "║ 下一步: ║"
echo "║ uci set openclaw.main.enabled=1 ║"
echo "║ uci commit openclaw ║"
echo "║ /etc/init.d/openclaw enable ║"
echo "║ /etc/init.d/openclaw start ║"
echo "║ ║"
echo "║ 或在 LuCI → 服务 → OpenClaw 中启用 ║"
echo "╚══════════════════════════════════════════════════════════════╝"
}
do_check() {
echo "=== OpenClaw 环境检查 ==="
echo ""
# Node.js
if [ -x "$NODE_BIN" ]; then
echo " Node.js: $($NODE_BIN --version 2>/dev/null)"
else
echo " Node.js: 未安装"
fi
# pnpm
if [ -x "$PNPM_BIN" ]; then
echo " pnpm: $($PNPM_BIN --version 2>/dev/null)"
elif [ -x "${NODE_BASE}/bin/pnpm" ]; then
echo " pnpm: $(${NODE_BASE}/bin/pnpm --version 2>/dev/null)"
else
echo " pnpm: 未安装"
fi
# OpenClaw
local oc_entry=""
oc_entry=$(find_oc_entry)
if [ -n "$oc_entry" ] && [ -x "$NODE_BIN" ]; then
echo " OpenClaw: v$($NODE_BIN $oc_entry --version 2>/dev/null | tr -d '[:space:]')"
else
echo " OpenClaw: 未安装"
fi
# 配置文件
if [ -f "$OC_DATA/.openclaw/openclaw.json" ]; then
echo " 配置: $OC_DATA/.openclaw/openclaw.json (存在)"
else
echo " 配置: 未初始化"
fi
# 磁盘使用
local used
used=$(du -sh /opt/openclaw 2>/dev/null | awk '{print $1}')
echo " 磁盘: ${used:-N/A}"
# libc 类型
echo " C库: $(detect_libc)"
}
do_upgrade() {
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ 一万AI分享 OpenClaw 升级 ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
if [ ! -x "$NODE_BIN" ]; then
log_error "Node.js 未安装, 请先运行: openclaw-env setup"
exit 1
fi
# 获取当前版本
local current_ver=""
local oc_entry=""
oc_entry=$(find_oc_entry)
if [ -n "$oc_entry" ]; then
local oc_pkg_dir="$(dirname "$oc_entry")"
[ -f "$oc_pkg_dir/package.json" ] && \
current_ver=$("$NODE_BIN" -e "try{console.log(require('$oc_pkg_dir/package.json').version)}catch(e){}" 2>/dev/null) || true
fi
echo " Node.js: $($NODE_BIN --version 2>/dev/null)"
[ -n "$current_ver" ] && echo " 当前版本: v${current_ver}"
echo ""
local libc_type
libc_type=$(detect_libc)
local install_flags=""
[ "$libc_type" = "musl" ] && install_flags="--ignore-scripts"
echo "=== 正在升级 OpenClaw ==="
echo ""
"$NPM_BIN" install -g openclaw@latest --prefix="$OC_GLOBAL" $install_flags 2>&1
# 验证升级结果
local new_ver=""
local new_entry=""
new_entry=$(find_oc_entry)
if [ -n "$new_entry" ]; then
local new_pkg_dir="$(dirname "$new_entry")"
[ -f "$new_pkg_dir/package.json" ] && \
new_ver=$("$NODE_BIN" -e "try{console.log(require('$new_pkg_dir/package.json').version)}catch(e){}" 2>/dev/null) || true
fi
echo ""
if [ -n "$new_ver" ]; then
if [ "$current_ver" = "$new_ver" ]; then
log_info "当前已是最新版本 v${new_ver}"
else
log_info "升级成功: v${current_ver} → v${new_ver}"
fi
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ ✅ 升级完成! ║"
echo "╚══════════════════════════════════════════════════════════════╝"
else
log_error "升级验证失败OpenClaw 入口文件未找到"
exit 1
fi
}
# ── 主入口 ──
case "${1:-}" in
setup)
do_setup
;;
check)
do_check
;;
upgrade)
do_upgrade
;;
node)
download_node "$NODE_VERSION"
;;
*)
echo "用法: openclaw-env {setup|check|upgrade|node}"
echo ""
echo " setup — 完整安装 (下载 Node.js + pnpm + OpenClaw)"
echo " check — 检查环境状态"
echo " upgrade — 升级 OpenClaw 到最新版"
echo " node — 仅下载/更新 Node.js"
exit 1
;;
esac

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,280 @@
<!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>一万AI分享 OpenClaw 配置管理</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{font-size:11px;color:#3b3d57;margin-top:12px;max-width:90%;word-break:break-all;text-align:center}
#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">🦞 一万AI分享 OpenClaw 配置管理</span>
<span id="status" class="status disconnected">● 连接中...</span>
<button class="btn" onclick="location.reload()" title="重新启动配置脚本">🔄 重启</button>
</div>
<div id="loading">
<div class="spinner"></div>
<h2>🦞 一万AI分享 OpenClaw 配置管理工具</h2>
<p id="loading-msg">正在连接终端...</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">⚡ 连接已断开</p>
<p style="font-size:13px;color:#565f89">配置脚本已退出或连接中断</p>
<button onclick="location.reload()">🔄 重新启动</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: 15,
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) { /* optional */ }
term.open(containerEl);
function doFit() {
try { fitAddon.fit(); } catch(e) { /* ignore */ }
try { term.scrollToBottom(); } catch(e) { /* ignore */ }
}
doFit();
window.addEventListener('resize', () => { setTimeout(doFit, 100); });
// 监听 iframe 容器大小变化 (FnOS 桌面可能调整窗口大小)
if (typeof ResizeObserver !== 'undefined') {
new ResizeObserver(function() { setTimeout(doFit, 50); }).observe(containerEl);
}
// ── WebSocket 连接 ──
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
// 从 URL 参数或 hash 获取 PTY token 用于 WebSocket 认证
const urlParams = new URLSearchParams(location.search);
const ptyToken = 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 = 20;
let retryTimer = null;
let wasEverConnected = false;
console.log('[oc-config] protocol:', location.protocol);
console.log('[oc-config] host:', location.host, 'port:', location.port);
console.log('[oc-config] WebSocket URL:', wsUrl);
loadingDebug.textContent = wsUrl;
function connect() {
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
if (ws) { try { ws.close(); } catch(e){} ws = null; }
retryCount++;
console.log('[oc-config] Connecting to', wsUrl, '(attempt', retryCount + ')');
loadingMsg.textContent = retryCount > 1
? '正在重新连接... (第 ' + retryCount + ' 次)'
: '正在连接终端...';
loadingDebug.textContent = wsUrl;
// 先做一个 HTTP 预检确认服务器可达
fetch('/health').then(function(r) {
return r.json();
}).then(function(data) {
console.log('[oc-config] Health check OK:', JSON.stringify(data));
doWebSocket();
}).catch(function(err) {
console.log('[oc-config] Health check failed:', err.message || err);
// 服务器不可达,稍后重试
scheduleRetry();
});
}
function doWebSocket() {
try {
ws = new WebSocket(wsUrl);
} catch(e) {
console.error('[oc-config] WebSocket constructor error:', e);
scheduleRetry();
return;
}
console.log('[oc-config] WebSocket created, readyState:', ws.readyState);
// 连接超时保护: 5秒没连上就重试
var connectTimeout = setTimeout(function() {
if (ws && ws.readyState !== WebSocket.OPEN) {
console.log('[oc-config] Connection timeout, readyState:', ws.readyState);
try { ws.close(); } catch(e){}
scheduleRetry();
}
}, 5000);
ws.onopen = function() {
clearTimeout(connectTimeout);
connected = true;
wasEverConnected = true;
retryCount = 0;
loadingEl.classList.add('hidden');
reconnectEl.classList.remove('show');
statusEl.textContent = '● 已连接';
statusEl.className = 'status connected';
// 连接后重新 fit确保尺寸正确
setTimeout(doFit, 50);
term.focus();
try {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
} catch(e) {}
};
ws.onmessage = function(ev) {
if (typeof ev.data === 'string') {
term.write(ev.data, function() { term.scrollToBottom(); });
} else if (ev.data instanceof Blob) {
ev.data.text().then(function(text) { term.write(text, function() { term.scrollToBottom(); }); });
}
};
ws.onclose = function(ev) {
clearTimeout(connectTimeout);
connected = false;
statusEl.textContent = '● 已断开';
statusEl.className = 'status disconnected';
console.log('[oc-config] WebSocket closed, code:', ev.code, 'reason:', ev.reason);
if (wasEverConnected) {
// 自动重连,不显示断连覆盖层
console.log('[oc-config] Auto-reconnecting in 2s...');
statusEl.textContent = '● 重连中...';
retryCount = 0;
setTimeout(function() {
connect();
}, 2000);
} else {
// 从未连接成功过,自动重试
scheduleRetry();
}
};
ws.onerror = function(ev) {
clearTimeout(connectTimeout);
connected = false;
console.error('[oc-config] WebSocket error, will retry');
// 不要在这里做UI更新, onclose 会紧跟着触发
};
}
function scheduleRetry() {
if (retryCount >= MAX_RETRY) {
loadingMsg.textContent = '连接失败,请检查服务状态';
loadingDebug.textContent = '已重试 ' + MAX_RETRY + ' 次。请刷新页面重试。';
statusEl.textContent = '● 连接失败';
statusEl.className = 'status disconnected';
return;
}
// 逐步增加重试间隔: 1s, 1s, 2s, 2s, 3s, 3s, ...
var delay = Math.min(Math.floor(retryCount / 2) + 1, 5) * 1000;
loadingMsg.textContent = '等待服务就绪... ' + Math.ceil(delay/1000) + '秒后重试';
console.log('[oc-config] Retry in', delay, 'ms');
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 }));
}
});
// 等 DOM 和资源就绪后再连接
if (document.readyState === 'complete') {
connect();
} else {
window.addEventListener('load', connect);
}
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map

View File

@@ -0,0 +1,8 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(self,(()=>(()=>{"use strict";var e={6:(e,t)=>{function n(e){try{const t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch(e){return!1}}Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,o={}){this._terminal=e,this._regex=t,this._handler=n,this._options=o}provideLinks(e,t){const n=o.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:o}=e;this._options.hover(t,n,o)}},e)))}};class o{static computeLink(e,t,r,i){const s=new RegExp(t.source,(t.flags||"")+"g"),[a,c]=o._getWindowedLineStrings(e-1,r),l=a.join("");let d;const p=[];for(;d=s.exec(l);){const e=d[0];if(!n(e))continue;const[t,s]=o._mapStrIdx(r,c,0,d.index),[a,l]=o._mapStrIdx(r,t,s,e.length);if(-1===t||-1===s||-1===a||-1===l)continue;const h={start:{x:s+1,y:t+1},end:{x:l,y:a+1}};p.push({range:h,text:e,activate:i})}return p}static _getWindowedLineStrings(e,t){let n,o=e,r=e,i=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(i=0;(n=t.buffer.active.getLine(--o))&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),i=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,o]}static _mapStrIdx(e,t,n,o){const r=e.buffer.active,i=r.getNullCell();let s=n;for(;o;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,i);const s=i.getChars();if(i.getWidth()&&(o-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,i),2===i.getWidth()&&(o+=1))}if(o<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=o}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}var o={};return(()=>{var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(6),r=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function i(e,t){const n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=i,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,o=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,o,this._handler,n))}dispose(){this._linkProvider?.dispose()}}})(),o})()));
//# sourceMappingURL=addon-web-links.js.map

View File

@@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using clean-css v5.3.3.
* Original file: /npm/@xterm/xterm@5.5.0/css/xterm.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:0}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm .xterm-cursor-pointer,.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:transparent}.xterm .xterm-accessibility-tree{user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}
/*# sourceMappingURL=/sm/97377c0c258e109358121823f5790146c714989366481f90e554c42277efb500.map */

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,285 @@
#!/usr/bin/env node
// ============================================================================
// OpenClaw 配置工具 — Web PTY 服务器
// 纯 Node.js 实现,零外部依赖
// 通过 WebSocket 将 oc-config.sh 的 TTY 输出推送给浏览器 xterm.js
// HTTP 端口 18793, HTTPS 可选端口 18794
// ============================================================================
const http = require('http');
const https = require('https');
const crypto = require('crypto');
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
// ── 配置 (OpenWrt 适配) ──
const PORT = parseInt(process.env.OC_CONFIG_PORT || '18793', 10);
const HOST = process.env.OC_CONFIG_HOST || '0.0.0.0'; // token 认证保护,可安全绑定所有接口
const NODE_BASE = process.env.NODE_BASE || '/opt/openclaw/node';
const OC_GLOBAL = process.env.OC_GLOBAL || '/opt/openclaw/global';
const OC_DATA = process.env.OC_DATA || '/opt/openclaw/data';
const SCRIPT_PATH = process.env.OC_CONFIG_SCRIPT || '/usr/share/openclaw/oc-config.sh';
const SSL_CERT = '/etc/uhttpd.crt';
const SSL_KEY = '/etc/uhttpd.key';
const MAX_SESSIONS = parseInt(process.env.OC_MAX_SESSIONS || '5', 10);
// ── 认证令牌 (从 UCI 或环境变量读取) ──
function loadAuthToken() {
try {
const { execSync } = require('child_process');
const t = execSync('uci -q get openclaw.main.luci_token 2>/dev/null', { encoding: 'utf8', timeout: 3000 }).trim();
return t || '';
} catch { return ''; }
}
let AUTH_TOKEN = process.env.OC_PTY_TOKEN || loadAuthToken();
// ── 会话计数 ──
let activeSessions = 0;
// ── 静态文件 ──
const UI_DIR = path.join(__dirname, 'ui');
function getMimeType(ext) {
const types = {
'.html': 'text/html; charset=utf-8', '.css': 'text/css',
'.js': 'application/javascript', '.png': 'image/png',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json',
};
return types[ext] || 'application/octet-stream';
}
const IFRAME_HEADERS = {
'Access-Control-Allow-Origin': '*',
'X-Frame-Options': 'ALLOWALL',
'Content-Security-Policy': "default-src * 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss:; frame-ancestors *",
};
// ── WebSocket 帧处理 (RFC 6455) ──
function decodeWSFrame(buf) {
if (buf.length < 2) return null;
const opcode = buf[0] & 0x0f;
const masked = !!(buf[1] & 0x80);
let payloadLen = buf[1] & 0x7f;
let offset = 2;
if (payloadLen === 126) {
if (buf.length < 4) return null;
payloadLen = buf.readUInt16BE(2); offset = 4;
} else if (payloadLen === 127) {
if (buf.length < 10) return null;
payloadLen = Number(buf.readBigUInt64BE(2)); offset = 10;
}
let mask = null;
if (masked) {
if (buf.length < offset + 4) return null;
mask = buf.slice(offset, offset + 4); offset += 4;
}
if (buf.length < offset + payloadLen) return null;
const data = buf.slice(offset, offset + payloadLen);
if (mask) { for (let i = 0; i < data.length; i++) data[i] ^= mask[i & 3]; }
return { opcode, data, totalLen: offset + payloadLen };
}
function encodeWSFrame(data, opcode = 0x01) {
const payload = typeof data === 'string' ? Buffer.from(data) : data;
const len = payload.length;
let header;
if (len < 126) {
header = Buffer.alloc(2); header[0] = 0x80 | opcode; header[1] = len;
} else if (len < 65536) {
header = Buffer.alloc(4); header[0] = 0x80 | opcode; header[1] = 126; header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(10); header[0] = 0x80 | opcode; header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2);
}
return Buffer.concat([header, payload]);
}
// ── PTY 进程管理 ──
class PtySession {
constructor(socket) {
this.socket = socket;
this.proc = null;
this.cols = 80;
this.rows = 24;
this.buffer = Buffer.alloc(0);
this.alive = true;
activeSessions++;
console.log(`[oc-config] Session created (active: ${activeSessions}/${MAX_SESSIONS})`);
this._setupWSReader();
this._spawnPty();
}
_setupWSReader() {
this.socket.on('data', (chunk) => {
this.buffer = Buffer.concat([this.buffer, chunk]);
while (this.buffer.length > 0) {
const frame = decodeWSFrame(this.buffer);
if (!frame) break;
this.buffer = this.buffer.slice(frame.totalLen);
if (frame.opcode === 0x01) this._handleMessage(frame.data.toString());
else if (frame.opcode === 0x02 && this.proc && this.proc.stdin.writable) this.proc.stdin.write(frame.data);
else if (frame.opcode === 0x08) { console.log('[oc-config] WS close frame received'); this._cleanup(); }
else if (frame.opcode === 0x09) this.socket.write(encodeWSFrame(frame.data, 0x0a));
}
});
this.socket.on('close', (hadError) => { console.log(`[oc-config] Socket closed, hadError=${hadError}`); this._cleanup(); });
this.socket.on('error', (err) => { console.log(`[oc-config] Socket error: ${err.message}`); this._cleanup(); });
}
_handleMessage(text) {
try {
const msg = JSON.parse(text);
if (msg.type === 'stdin' && this.proc && this.proc.stdin.writable) {
// 去除 bracketed paste 转义序列,避免污染 shell read 输入
const cleaned = msg.data.replace(/\x1b\[\?2004[hl]/g, '').replace(/\x1b\[20[01]~/g, '');
this.proc.stdin.write(cleaned);
}
else if (msg.type === 'resize') {
this.cols = msg.cols || 80; this.rows = msg.rows || 24;
if (this.proc && this.proc.pid) { try { process.kill(-this.proc.pid, 'SIGWINCH'); } catch(e){} }
}
} catch(e) { if (this.proc && this.proc.stdin.writable) this.proc.stdin.write(text); }
}
_spawnPty() {
const env = {
...process.env, TERM: 'xterm-256color', COLUMNS: String(this.cols), LINES: String(this.rows),
COLORTERM: 'truecolor', LANG: 'en_US.UTF-8',
NODE_BASE, OC_GLOBAL, OC_DATA,
HOME: OC_DATA,
OPENCLAW_HOME: OC_DATA,
OPENCLAW_STATE_DIR: `${OC_DATA}/.openclaw`,
OPENCLAW_CONFIG_PATH: `${OC_DATA}/.openclaw/openclaw.json`,
PATH: `${NODE_BASE}/bin:${OC_GLOBAL}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
};
this.proc = spawn('script', ['-qc', `stty rows ${this.rows} cols ${this.cols} 2>/dev/null; printf '\\e[?2004l'; sh "${SCRIPT_PATH}"`, '/dev/null'],
{ stdio: ['pipe', 'pipe', 'pipe'], env, detached: true });
this.proc.stdout.on('data', (d) => { if (this.alive) this.socket.write(encodeWSFrame(d, 0x01)); });
this.proc.stderr.on('data', (d) => { if (this.alive) this.socket.write(encodeWSFrame(d, 0x01)); });
this.proc.on('close', (code) => {
if (!this.alive) return;
console.log(`[oc-config] Script exited with code ${code}, auto-restarting...`);
this.socket.write(encodeWSFrame(`\r\n\x1b[33m配置脚本已退出 (code: ${code}),正在自动重启...\x1b[0m\r\n`, 0x01));
this.proc = null;
// 自动重启脚本,保持 WebSocket 连接
setTimeout(() => {
if (this.alive) {
this._spawnPty();
}
}, 1500);
});
this.proc.on('error', (err) => {
if (this.alive) this.socket.write(encodeWSFrame(`\r\n\x1b[31m启动失败: ${err.message}\x1b[0m\r\n`, 0x01));
});
}
_cleanup() {
if (!this.alive) return; this.alive = false;
activeSessions = Math.max(0, activeSessions - 1);
console.log(`[oc-config] Session ended (active: ${activeSessions}/${MAX_SESSIONS})`);
if (this.proc) { try { process.kill(-this.proc.pid, 'SIGTERM'); } catch(e){} try { this.proc.kill('SIGTERM'); } catch(e){} }
try { this.socket.destroy(); } catch(e){}
}
}
// ── HTTP 请求处理 ──
function handleRequest(req, res) {
const url = new URL(req.url, `http://${req.headers.host}`);
let fp = url.pathname;
if (req.method === 'OPTIONS') {
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': '*' });
return res.end();
}
if (fp === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
return res.end(JSON.stringify({ status: 'ok', port: PORT, uptime: process.uptime() }));
}
if (fp === '/' || fp === '') fp = '/index.html';
const fullPath = path.join(UI_DIR, fp);
if (!fullPath.startsWith(UI_DIR)) { res.writeHead(403); return res.end('Forbidden'); }
fs.readFile(fullPath, (err, data) => {
if (err) {
if (fp !== '/index.html') {
fs.readFile(path.join(UI_DIR, 'index.html'), (e2, d2) => {
if (e2) { res.writeHead(404); res.end('Not Found'); }
else { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...IFRAME_HEADERS }); res.end(d2); }
});
} else { res.writeHead(404); res.end('Not Found'); }
return;
}
const ext = path.extname(fullPath);
res.writeHead(200, { 'Content-Type': getMimeType(ext), 'Cache-Control': ext === '.html' ? 'no-cache' : 'max-age=3600', ...IFRAME_HEADERS });
res.end(data);
});
}
// ── WebSocket Upgrade ──
function handleUpgrade(req, socket, head) {
console.log(`[oc-config] WS upgrade: ${req.url} remote=${socket.remoteAddress}:${socket.remotePort}`);
if (req.url !== '/ws' && !req.url.startsWith('/ws?')) { socket.destroy(); return; }
// 认证: 验证查询参数中的 token
if (AUTH_TOKEN) {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const clientToken = url.searchParams.get('token') || '';
if (clientToken !== AUTH_TOKEN) {
console.log(`[oc-config] WS auth failed from ${socket.remoteAddress}`);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
}
// 并发会话限制
if (activeSessions >= MAX_SESSIONS) {
console.log(`[oc-config] Max sessions reached (${activeSessions}/${MAX_SESSIONS}), rejecting`);
socket.write('HTTP/1.1 503 Service Unavailable\r\n\r\n');
socket.destroy();
return;
}
const key = req.headers['sec-websocket-key'];
if (!key) { console.log('[oc-config] Missing Sec-WebSocket-Key'); socket.destroy(); return; }
const accept = crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
socket.setNoDelay(true);
socket.setTimeout(0);
const handshake = 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + '\r\n\r\n';
socket.write(handshake, () => {
if (head && head.length > 0) socket.unshift(head);
new PtySession(socket);
console.log('[oc-config] PTY session started');
});
}
// ── 服务器实例 ──
const httpServer = http.createServer(handleRequest);
httpServer.on('upgrade', handleUpgrade);
httpServer.listen(PORT, HOST, () => {
console.log(`[oc-config] HTTP listening on ${HOST}:${PORT}`);
console.log(`[oc-config] Script: ${SCRIPT_PATH}`);
});
// HTTPS 可选端口 PORT+1
const HTTPS_PORT = PORT + 1;
try {
if (fs.existsSync(SSL_CERT) && fs.existsSync(SSL_KEY)) {
const httpsServer = https.createServer({ cert: fs.readFileSync(SSL_CERT), key: fs.readFileSync(SSL_KEY) }, handleRequest);
httpsServer.on('upgrade', handleUpgrade);
httpsServer.listen(HTTPS_PORT, HOST, () => console.log(`[oc-config] HTTPS listening on ${HOST}:${HTTPS_PORT}`));
httpsServer.on('error', (e) => console.log(`[oc-config] HTTPS port ${HTTPS_PORT}: ${e.message}`));
}
} catch (e) { console.log(`[oc-config] SSL init: ${e.message}`); }
httpServer.on('error', (e) => { console.error(`[oc-config] Fatal: ${e.message}`); process.exit(1); });
process.on('SIGTERM', () => { console.log('[oc-config] Shutdown'); httpServer.close(); process.exit(0); });
process.on('SIGINT', () => { httpServer.close(); process.exit(0); });