From cb0fc3c5714bb394a262a091b3843cd0da81dc91 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 17 Mar 2026 20:27:41 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9B=85=20Sync=202026-03-17=2020:27:41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- luci-app-openclaw/Makefile | 2 +- .../luasrc/controller/openclaw.lua | 82 +- .../luasrc/model/cbi/openclaw/basic.lua | 75 +- luci-app-openclaw/root/etc/init.d/openclaw | 102 +- luci-app-openclaw/root/usr/bin/openclaw-env | 17 +- .../root/usr/share/openclaw/oc-config.sh | 62 + luci-app-openclawmgr/Makefile | 29 + .../luci-static/resources/openclawmgr/app.css | 583 +++++++++ .../luci-static/resources/openclawmgr/app.js | 798 +++++++++++ .../resources/openclawmgr/openclaw.svg | 17 + .../luasrc/controller/openclawmgr.lua | 784 +++++++++++ .../luasrc/model/openclawmgr.lua | 54 + .../luasrc/view/openclawmgr/app.htm | 19 + .../luasrc/view/openclawmgr/diag.htm | 258 ++++ luci-app-openclawmgr/po/zh-cn/openclawmgr.po | 245 ++++ luci-app-openclawmgr/po/zh_Hans | 1 + .../root/etc/config/openclawmgr | 14 + .../root/etc/init.d/openclawmgr | 87 ++ .../root/etc/uci-defaults/99-openclawmgr | 46 + .../root/usr/libexec/istorec/openclawmgr.sh | 1161 +++++++++++++++++ .../rpcd/acl.d/luci-app-openclawmgr.json | 11 + mtproxy/Makefile | 6 +- 22 files changed, 4371 insertions(+), 82 deletions(-) create mode 100644 luci-app-openclawmgr/Makefile create mode 100644 luci-app-openclawmgr/htdocs/luci-static/resources/openclawmgr/app.css create mode 100644 luci-app-openclawmgr/htdocs/luci-static/resources/openclawmgr/app.js create mode 100644 luci-app-openclawmgr/htdocs/luci-static/resources/openclawmgr/openclaw.svg create mode 100644 luci-app-openclawmgr/luasrc/controller/openclawmgr.lua create mode 100644 luci-app-openclawmgr/luasrc/model/openclawmgr.lua create mode 100644 luci-app-openclawmgr/luasrc/view/openclawmgr/app.htm create mode 100644 luci-app-openclawmgr/luasrc/view/openclawmgr/diag.htm create mode 100644 luci-app-openclawmgr/po/zh-cn/openclawmgr.po create mode 120000 luci-app-openclawmgr/po/zh_Hans create mode 100644 luci-app-openclawmgr/root/etc/config/openclawmgr create mode 100755 luci-app-openclawmgr/root/etc/init.d/openclawmgr create mode 100755 luci-app-openclawmgr/root/etc/uci-defaults/99-openclawmgr create mode 100755 luci-app-openclawmgr/root/usr/libexec/istorec/openclawmgr.sh create mode 100644 luci-app-openclawmgr/root/usr/share/rpcd/acl.d/luci-app-openclawmgr.json diff --git a/luci-app-openclaw/Makefile b/luci-app-openclaw/Makefile index c6caa4cc..09fd20be 100644 --- a/luci-app-openclaw/Makefile +++ b/luci-app-openclaw/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-openclaw PKG_VERSION:=$(strip $(shell cat $(CURDIR)/VERSION 2>/dev/null || echo "1.0.0")) -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_MAINTAINER:=10000ge10000 <10000ge10000@users.noreply.github.com> PKG_LICENSE:=GPL-3.0 diff --git a/luci-app-openclaw/luasrc/controller/openclaw.lua b/luci-app-openclaw/luasrc/controller/openclaw.lua index 4a96cca5..4a83e932 100644 --- a/luci-app-openclaw/luasrc/controller/openclaw.lua +++ b/luci-app-openclaw/luasrc/controller/openclaw.lua @@ -41,6 +41,9 @@ function index() -- 配置备份 API (v2026.3.8+: openclaw backup create/verify) entry({"admin", "services", "openclaw", "backup"}, call("action_backup"), nil).leaf = true + + -- 系统配置检测 API (安装前检测) + entry({"admin", "services", "openclaw", "check_system"}, call("action_check_system"), nil).leaf = true end -- ═══════════════════════════════════════════ @@ -372,19 +375,21 @@ function action_uninstall() sys.exec("/etc/init.d/openclaw disable 2>/dev/null") -- 设置 UCI enabled=0 sys.exec("uci set openclaw.main.enabled=0; uci commit openclaw 2>/dev/null") - -- 删除 Node.js + OpenClaw 运行环境 + -- 删除 Node.js + OpenClaw 运行环境 (包含所有插件: qqbot, 飞书等) sys.exec("rm -rf /opt/openclaw") -- 清理旧数据迁移后可能残留的目录 sys.exec("rm -rf /root/.openclaw 2>/dev/null") -- 清理临时文件 - sys.exec("rm -f /tmp/openclaw-setup.* /tmp/openclaw-update.log /var/run/openclaw*.pid") + sys.exec("rm -f /tmp/openclaw-setup.* /tmp/openclaw-update.log /tmp/openclaw-plugin-upgrade.* /var/run/openclaw*.pid") + -- 清理 LuCI 缓存 + sys.exec("rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null") -- 删除 openclaw 系统用户 sys.exec("sed -i '/^openclaw:/d' /etc/passwd /etc/shadow /etc/group 2>/dev/null") http.prepare_content("application/json") http.write_json({ status = "ok", - message = "运行环境已卸载。已清理: Node.js 运行环境 (/opt/openclaw)、旧数据目录 (/root/.openclaw)、临时文件。" + message = "运行环境已卸载。已清理: Node.js 运行环境 (/opt/openclaw)、所有插件 (qqbot/飞书等)、旧数据目录 (/root/.openclaw)、临时文件、LuCI 缓存。" }) end @@ -801,3 +806,74 @@ function action_backup() http.write_json({ status = "error", message = "未知备份操作: " .. action }) end end + +-- ═══════════════════════════════════════════ +-- 系统配置检测 API (安装前检测) +-- 检测内存和磁盘空间是否满足最低要求 +-- 要求: 内存 > 1GB, 磁盘可用空间 > 1.5GB +-- ═══════════════════════════════════════════ +function action_check_system() + local http = require "luci.http" + local sys = require "luci.sys" + + -- 最低要求配置 + local MIN_MEMORY_MB = 1024 -- 1GB + local MIN_DISK_MB = 1536 -- 1.5GB + + local result = { + memory_mb = 0, + memory_ok = false, + disk_mb = 0, + disk_ok = false, + disk_path = "", + pass = false, + message = "" + } + + -- 检测总内存 (从 /proc/meminfo 读取 MemTotal) + local meminfo = io.open("/proc/meminfo", "r") + if meminfo then + for line in meminfo:lines() do + local mem_total = line:match("MemTotal:%s+(%d+)%s+kB") + if mem_total then + result.memory_mb = math.floor(tonumber(mem_total) / 1024) + break + end + end + meminfo:close() + end + result.memory_ok = result.memory_mb >= MIN_MEMORY_MB + + -- 检测磁盘可用空间 + -- 优先检测 /opt 所在分区,如果 /opt 不存在则检测 /overlay 或 / + local disk_paths = {"/opt", "/overlay", "/"} + for _, path in ipairs(disk_paths) do + local df_output = sys.exec("df -m " .. path .. " 2>/dev/null | tail -1 | awk '{print $4}'"):gsub("%s+", "") + if df_output and df_output ~= "" and tonumber(df_output) then + result.disk_mb = tonumber(df_output) + result.disk_path = path + break + end + end + result.disk_ok = result.disk_mb >= MIN_DISK_MB + + -- 综合判断 + result.pass = result.memory_ok and result.disk_ok + + -- 生成提示信息 + if result.pass then + result.message = "系统配置检测通过" + else + local issues = {} + if not result.memory_ok then + table.insert(issues, string.format("内存不足: 当前 %d MB,需要至少 %d MB", result.memory_mb, MIN_MEMORY_MB)) + end + if not result.disk_ok then + table.insert(issues, string.format("磁盘空间不足: 当前 %d MB 可用,需要至少 %d MB", result.disk_mb, MIN_DISK_MB)) + end + result.message = table.concat(issues, ";") + end + + http.prepare_content("application/json") + http.write_json(result) +end diff --git a/luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua b/luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua index 66af6a37..cd0b87db 100644 --- a/luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua +++ b/luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua @@ -27,6 +27,7 @@ act.cfgvalue = function(self, section) local uninstall_url = luci.dispatcher.build_url("admin", "services", "openclaw", "uninstall") local plugin_upgrade_url = luci.dispatcher.build_url("admin", "services", "openclaw", "plugin_upgrade") local plugin_upgrade_log_url = luci.dispatcher.build_url("admin", "services", "openclaw", "plugin_upgrade_log") + local check_system_url = luci.dispatcher.build_url("admin", "services", "openclaw", "check_system") local html = {} -- 按钮区域 @@ -91,30 +92,64 @@ act.cfgvalue = function(self, section) html[#html+1] = 'document.getElementById("oc-setup-dialog").style.display="none";' html[#html+1] = '}' html[#html+1] = 'function ocConfirmSetup(){' - html[#html+1] = 'ocCloseSetupDialog();' - html[#html+1] = 'var radios=document.getElementsByName("oc-ver-choice");' - html[#html+1] = 'var choice="stable";' - html[#html+1] = 'for(var i=0;i/dev/null 2>&1; then + ss -tulnp 2>/dev/null | grep -q ":${p} " || return 0 + else + netstat -tulnp 2>/dev/null | grep -q ":${p} " || return 0 + fi + + # 端口被占用,尝试清理 + for occ_pid in $(pgrep -f "openclaw-gateway" 2>/dev/null); do + kill "$occ_pid" 2>/dev/null + done + while [ $i -lt $max_wait ]; do if command -v ss >/dev/null 2>&1; then ss -tulnp 2>/dev/null | grep -q ":${p} " || return 0 else netstat -tulnp 2>/dev/null | grep -q ":${p} " || return 0 fi - # 尝试杀掉占用端口的 gateway 进程 - if [ $i -eq 0 ]; then - local occ_pid - for occ_pid in $(pgrep -f "openclaw-gateway" 2>/dev/null); do - kill "$occ_pid" 2>/dev/null - done - fi - sleep 1 + usleep 200000 2>/dev/null || sleep 0.2 i=$((i + 1)) done + # 最后手段: SIGKILL local port_pid="" if command -v ss >/dev/null 2>&1; then @@ -283,7 +292,7 @@ _ensure_port_free() { else port_pid=$(netstat -tulnp 2>/dev/null | grep ":${p} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1) fi - [ -n "$port_pid" ] && kill -9 "$port_pid" 2>/dev/null && sleep 1 + [ -n "$port_pid" ] && kill -9 "$port_pid" 2>/dev/null && usleep 300000 2>/dev/null return 0 } _ensure_port_free "$port" @@ -350,36 +359,43 @@ stop_service() { local port port=$(uci -q get openclaw.main.port || echo "18789") -# 杀掉所有 openclaw / openclaw-gateway 残留进程 -# (排除 web-pty.js 和 oc-config.sh,它们由 pty 实例管理) -local pid -for pid in $(pgrep -f "openclaw-gateway" 2>/dev/null) \ - $(pgrep -f "openclaw.*gateway.*run" 2>/dev/null); do - kill "$pid" 2>/dev/null +# v2026.3.14 优化: 快速终止进程树,减少等待时间 +# 1) 先获取所有相关 PID +local gw_pids="" +gw_pids=$(pgrep -f "openclaw-gateway" 2>/dev/null) +gw_pids="$gw_pids $(pgrep -f "openclaw.*gateway.*run" 2>/dev/null)" + +# 2) 同时发送 SIGTERM 给所有进程 +for pid in $gw_pids; do + [ -n "$pid" ] && kill "$pid" 2>/dev/null done -# 等待端口真正释放 (最多 8 秒) +# 3) 快速轮询等待端口释放 (200ms 间隔,最多 3 秒) local wait_count=0 -while [ $wait_count -lt 8 ]; do +local max_wait=15 # 15 * 0.2 = 3 秒 +while [ $wait_count -lt $max_wait ]; do if command -v ss >/dev/null 2>&1; then ss -tulnp 2>/dev/null | grep -q ":${port} " || break else netstat -tulnp 2>/dev/null | grep -q ":${port} " || break fi - sleep 1 + usleep 200000 2>/dev/null || sleep 0.2 wait_count=$((wait_count + 1)) done -# 如果端口仍被占用,强制杀掉占用者 -if [ $wait_count -ge 8 ]; then +# 4) 如果端口仍被占用,强制 SIGKILL +if [ $wait_count -ge $max_wait ]; then local port_pid="" if command -v ss >/dev/null 2>&1; then port_pid=$(ss -tulnp 2>/dev/null | grep ":${port} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1) else port_pid=$(netstat -tulnp 2>/dev/null | grep ":${port} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1) fi - [ -n "$port_pid" ] && kill -9 "$port_pid" 2>/dev/null - sleep 1 + if [ -n "$port_pid" ]; then + kill -9 "$port_pid" 2>/dev/null + # 等待内核回收 (缩短到 0.5 秒) + usleep 500000 2>/dev/null || sleep 0.5 + fi fi } @@ -390,8 +406,8 @@ procd_add_reload_trigger "openclaw" reload_service() { stop -# stop_service 已确保端口释放,但额外等待 1 秒让内核回收 -sleep 1 +# v2026.3.14: stop_service 已优化等待逻辑,缩短额外等待时间 +usleep 300000 2>/dev/null || sleep 0.3 start } @@ -407,47 +423,45 @@ restart_gateway() { local port port=$(uci -q get openclaw.main.port || echo "18789") -# ── 第一步: kill 监听端口的 gateway 子进程 (openclaw-gateway) ── -# openclaw 启动后会 fork 出 openclaw-gateway 子进程实际监听端口 -# 必须先杀子进程释放端口,否则 procd respawn 的新实例会因端口冲突而崩溃 -local port_pid="" +# v2026.3.14 优化: 快速终止并重启 +# 1) 同时获取端口 PID 和 procd 管理的 PID +local port_pid="" gw_pid="" if command -v ss >/dev/null 2>&1; then port_pid=$(ss -tulnp 2>/dev/null | grep ":${port} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1) else port_pid=$(netstat -tulnp 2>/dev/null | grep ":${port} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1) fi -[ -n "$port_pid" ] && kill "$port_pid" 2>/dev/null - -# ── 第二步: kill procd 管理的 gateway 主进程 (openclaw) ── -# procd 追踪的是主进程 PID,kill 它才能触发 respawn -local gw_pid="" gw_pid=$(ubus call service list '{"name":"openclaw"}' 2>/dev/null | \ jsonfilter -e '$.openclaw.instances.gateway.pid' 2>/dev/null) || true -if [ -n "$gw_pid" ] && kill -0 "$gw_pid" 2>/dev/null; then - kill "$gw_pid" 2>/dev/null -fi +# 2) 同时发送 SIGTERM 给所有相关进程 +[ -n "$port_pid" ] && kill "$port_pid" 2>/dev/null +[ -n "$gw_pid" ] && kill -0 "$gw_pid" 2>/dev/null && kill "$gw_pid" 2>/dev/null -# ── 第三步: 兜底 — kill 所有 openclaw gateway 相关残留进程 ── -# 避免任何残留进程占据端口 -sleep 1 +# 3) 兜底: kill 所有残留进程 for pid in $(pgrep -f "openclaw-gateway" 2>/dev/null) $(pgrep -f "openclaw.*gateway.*run" 2>/dev/null); do kill "$pid" 2>/dev/null done -# ── 第四步: 等待端口真正释放 (最多 5 秒) ── +# 4) 快速轮询等待端口释放 (200ms 间隔,最多 2 秒) local wait_count=0 -while [ $wait_count -lt 5 ]; do +while [ $wait_count -lt 10 ]; do if command -v ss >/dev/null 2>&1; then ss -tulnp 2>/dev/null | grep -q ":${port} " || break else netstat -tulnp 2>/dev/null | grep -q ":${port} " || break fi - sleep 1 + usleep 200000 2>/dev/null || sleep 0.2 wait_count=$((wait_count + 1)) done -# ── 第五步: 如果 procd 中没有 gateway 服务注册 (首次/崩溃),调用 start ── +# 5) 如果端口仍被占用,强制 SIGKILL +if [ $wait_count -ge 10 ]; then + [ -n "$port_pid" ] && kill -9 "$port_pid" 2>/dev/null + usleep 300000 2>/dev/null || sleep 0.3 +fi + +# 6) 如果 procd 中没有 gateway 服务注册,调用 start if [ -z "$gw_pid" ]; then /etc/init.d/openclaw start >/dev/null 2>&1 fi diff --git a/luci-app-openclaw/root/usr/bin/openclaw-env b/luci-app-openclaw/root/usr/bin/openclaw-env index 764618ed..621a0923 100755 --- a/luci-app-openclaw/root/usr/bin/openclaw-env +++ b/luci-app-openclaw/root/usr/bin/openclaw-env @@ -452,13 +452,8 @@ do_setup() { 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 "║ 如通过 LuCI 安装,服务已自动启用并启动。 ║" + echo "║ 如通过命令行安装,请在 LuCI → 服务 → OpenClaw 中启用。 ║" echo "╚══════════════════════════════════════════════════════════════╝" } @@ -724,13 +719,7 @@ do_setup_offline() { 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 "║ 请在 LuCI → 服务 → OpenClaw 中启用服务。 ║" echo "╚══════════════════════════════════════════════════════════════╝" } diff --git a/luci-app-openclaw/root/usr/share/openclaw/oc-config.sh b/luci-app-openclaw/root/usr/share/openclaw/oc-config.sh index f6d280b4..af9d5dde 100755 --- a/luci-app-openclaw/root/usr/share/openclaw/oc-config.sh +++ b/luci-app-openclaw/root/usr/share/openclaw/oc-config.sh @@ -2059,7 +2059,69 @@ reset_to_defaults() { if [ "$confirm" = "yes" ]; then echo "" echo -e " ${CYAN}正在清除渠道配置...${NC}" + # 清除 openclaw.json 中的 channels 配置 oc_cmd config unset channels >/dev/null 2>&1 || true + + # v2026.3.14: 同时清除 plugins 中与渠道相关的配置 + # 防止重置后插件配置残留导致状态不一致 + if [ -f "$CONFIG_FILE" ] && [ -x "$NODE_BIN" ]; then + "$NODE_BIN" -e " + const fs=require('fs'); + try{ + const d=JSON.parse(fs.readFileSync('${CONFIG_FILE}','utf8')); + let modified=false; + + // 清除 plugins.entries 中与消息渠道相关的插件 + if(d.plugins && d.plugins.entries){ + const channelPlugins=['openclaw-qqbot','@tencent-connect/openclaw-qqbot','openclaw-lark','@larksuite/openclaw-lark']; + channelPlugins.forEach(p=>{ + if(d.plugins.entries[p]){ + delete d.plugins.entries[p]; + modified=true; + } + }); + } + + // 清除 plugins.allow 中的渠道插件 + if(Array.isArray(d.plugins && d.plugins.allow)){ + const beforeLen=d.plugins.allow.length; + d.plugins.allow=d.plugins.allow.filter(p=> + !p.includes('qqbot') && + !p.includes('lark') && + !p.includes('telegram') && + !p.includes('discord') && + !p.includes('slack') && + !p.includes('whatsapp') + ); + if(d.plugins.allow.length!==beforeLen)modified=true; + } + + if(modified){ + fs.writeFileSync('${CONFIG_FILE}',JSON.stringify(d,null,2)); + console.log('CLEANED'); + } + }catch(e){} + " 2>/dev/null + chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true + fi + + # 清除飞书扩展目录中的敏感数据 (保留插件本体) + local feishu_ext_dir="${OC_STATE_DIR}/extensions/openclaw-lark" + if [ -d "$feishu_ext_dir" ]; then + # 只清除配置文件,保留插件代码 + rm -f "${feishu_ext_dir}/.credentials"* 2>/dev/null + rm -f "${feishu_ext_dir}/config.json" 2>/dev/null + rm -rf "${feishu_ext_dir}/.cache" 2>/dev/null + echo -e " ${CYAN}已清理飞书插件缓存数据${NC}" + fi + + # 清除 QQ 机器人扩展目录中的敏感数据 + local qqbot_ext_dir="${OC_STATE_DIR}/extensions/openclaw-qqbot" + if [ -d "$qqbot_ext_dir" ]; then + rm -f "${qqbot_ext_dir}/credentials"* 2>/dev/null + rm -f "${qqbot_ext_dir}/config.json" 2>/dev/null + fi + echo -e " ${GREEN}✅ 渠道配置已清除${NC}" echo -e " ${YELLOW}请通过菜单 [4] 重新配置消息渠道${NC}" ask_restart diff --git a/luci-app-openclawmgr/Makefile b/luci-app-openclawmgr/Makefile new file mode 100644 index 00000000..c6c95167 --- /dev/null +++ b/luci-app-openclawmgr/Makefile @@ -0,0 +1,29 @@ + +include $(TOPDIR)/rules.mk + +PKG_VERSION:=0.1.0 +PKG_RELEASE:=1 + +LUCI_TITLE:=LuCI support for OpenClaw Launcher +LUCI_PKGARCH:=all +LUCI_DEPENDS:=+luci-base +luci-compat +luci-lib-taskd +curl +ca-bundle +tar +xz-utils +jsonfilter + +define Package/luci-app-openclawmgr/conffiles +/etc/config/openclawmgr +endef + +define Package/luci-app-openclawmgr/prerm +#!/bin/sh +/etc/init.d/openclawmgr stop >/dev/null 2>&1 || true +exit 0 +endef + +define Package/luci-app-openclawmgr/postrm +#!/bin/sh +rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null +exit 0 +endef + +include $(TOPDIR)/feeds/luci/luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-openclawmgr/htdocs/luci-static/resources/openclawmgr/app.css b/luci-app-openclawmgr/htdocs/luci-static/resources/openclawmgr/app.css new file mode 100644 index 00000000..0fd9fbf3 --- /dev/null +++ b/luci-app-openclawmgr/htdocs/luci-static/resources/openclawmgr/app.css @@ -0,0 +1,583 @@ +.oclm-app { + --oclm-bg: #f5f7fb; + --oclm-card: #ffffff; + --oclm-border: #e7ebf3; + --oclm-text: #1f2937; + --oclm-subtle: #6b7280; + --oclm-line: #d9e1ee; + --oclm-primary: #2f67f6; + --oclm-primary-strong: #1f4fd4; + --oclm-danger: #ef4444; + --oclm-success: #12b981; + --oclm-warning: #f59e0b; + --oclm-shadow: 0 10px 28px rgba(15, 23, 42, 0.08); + color: var(--oclm-text); + font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.oclm-app[data-darkmode="true"] { + --oclm-bg: #101722; + --oclm-card: #182130; + --oclm-border: #2a3446; + --oclm-text: #edf2f7; + --oclm-subtle: #aeb8c8; + --oclm-line: #314056; + --oclm-primary: #5b86ff; + --oclm-primary-strong: #7ea0ff; + --oclm-danger: #ff7070; + --oclm-success: #34d399; + --oclm-warning: #fbbf24; + --oclm-shadow: 0 10px 28px rgba(0, 0, 0, 0.32); +} + +.oclm-root { + min-height: 100%; + background: linear-gradient(180deg, rgba(47, 103, 246, 0.06), transparent 180px), var(--oclm-bg); + padding: 20px; +} + +.oclm-shell { + max-width: 920px; +} + +.oclm-header { + margin-bottom: 18px; +} + +.oclm-header h1 { + margin: 0 0 6px; + font-size: 30px; + line-height: 1.15; + font-weight: 800; + display: inline-flex; + align-items: center; + gap: 12px; +} + +.oclm-header p { + margin: 0; + color: var(--oclm-subtle); +} + +.oclm-card { + background: var(--oclm-card); + border: 1px solid var(--oclm-border); + border-radius: 18px; + box-shadow: var(--oclm-shadow); + padding: 18px 20px; + margin-bottom: 18px; +} + +.oclm-card h2 { + margin: 0 0 14px; + font-size: 16px; + font-weight: 700; +} + +.oclm-tabs { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 18px; +} + +.oclm-tab { + appearance: none; + border: 1px solid var(--oclm-border); + background: transparent; + color: var(--oclm-subtle); + border-radius: 999px; + height: 38px; + padding: 0 16px; + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: .16s ease; +} + +.oclm-tab.is-active { + background: rgba(47, 103, 246, 0.10); + color: var(--oclm-primary); + border-color: rgba(47, 103, 246, 0.22); +} + +.oclm-status-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 20px; + margin-bottom: 14px; +} + +.oclm-status-row { + display: flex; + gap: 10px; + align-items: center; +} + +.oclm-status-label, +.oclm-meta-label { + color: var(--oclm-subtle); + font-size: 12px; + min-width: 36px; +} + +.oclm-status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 700; + min-height: 22px; +} + +.oclm-version-tags { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.oclm-tag { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 30px; + padding: 0 12px; + border-radius: 999px; + background: rgba(47, 103, 246, 0.08); + color: var(--oclm-primary); + font-size: 12px; + font-weight: 700; +} + +.oclm-tag-link { + text-decoration: none; +} + +.oclm-tag strong { + color: var(--oclm-text); + font-weight: 700; +} + +.oclm-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--oclm-warning); +} + +.oclm-status-spinner { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid rgba(47, 103, 246, 0.18); + border-top-color: var(--oclm-primary); + animation: oclm-status-spin var(--oclm-spin-duration, 15s) linear infinite; + flex: 0 0 14px; +} + +.oclm-dot.is-success { background: var(--oclm-success); } +.oclm-dot.is-danger { background: var(--oclm-danger); } + +@keyframes oclm-status-spin { + to { transform: rotate(360deg); } +} + +.oclm-status-actions, +.oclm-danger-actions, +.oclm-origin-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.oclm-status-note { + margin-top: 10px; + color: var(--oclm-subtle); + font-size: 12px; +} + +.oclm-install-inline { + display: inline-flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; +} + +.oclm-check { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: 10px; + color: var(--oclm-subtle); + font-size: 12px; +} + +.oclm-button { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid var(--oclm-border); + background: #fff; + color: var(--oclm-text); + border-radius: 10px; + height: 36px; + padding: 0 14px; + font-size: 13px; + font-weight: 600; + line-height: 1; + text-decoration: none; + white-space: nowrap; + cursor: pointer; + transition: .16s ease; +} + +.oclm-button-icon { + width: 16px; + height: 16px; + flex: 0 0 16px; +} + +.oclm-title-icon { + width: 28px; + height: 28px; + flex: 0 0 28px; +} + +.oclm-app[data-darkmode="true"] .oclm-button { + background: #162132; +} + +.oclm-button:hover { + border-color: var(--oclm-primary); + color: var(--oclm-primary); +} + +.oclm-button:disabled { + opacity: 0.65; + cursor: wait; + pointer-events: none; +} + +.oclm-button-primary { + background: linear-gradient(180deg, var(--oclm-primary), var(--oclm-primary-strong)); + color: #fff; + border-color: transparent; +} + +.oclm-button-primary:hover { + color: #fff; + filter: brightness(1.03); +} + +.oclm-button-danger { + color: #fff; + background: linear-gradient(180deg, #ff5d6d, #ef4444); + border-color: transparent; +} + +.oclm-form-grid { + display: grid; + gap: 14px; +} + +.oclm-field { + display: grid; + grid-template-columns: 150px minmax(0, 1fr); + gap: 14px; + align-items: start; +} + +.oclm-label { + padding-top: 10px; + font-weight: 600; + font-size: 13px; +} + +.oclm-control, +.oclm-select { + appearance: none; + width: 100%; + min-height: 40px; + border: 1px solid var(--oclm-line); + border-radius: 12px; + background: var(--oclm-card); + color: var(--oclm-text); + box-sizing: border-box; + padding: 10px 12px; + font-size: 14px; + outline: none; +} + +.oclm-control::placeholder { + color: #9ca3af; +} + +.oclm-select { + padding-right: 36px; + background-image: + linear-gradient(45deg, transparent 50%, #8ea0bf 50%), + linear-gradient(135deg, #8ea0bf 50%, transparent 50%); + background-position: + calc(100% - 18px) calc(50% - 2px), + calc(100% - 12px) calc(50% - 2px); + background-size: 6px 6px, 6px 6px; + background-repeat: no-repeat; +} + +.oclm-control:focus, +.oclm-select:focus { + border-color: var(--oclm-primary); + box-shadow: 0 0 0 3px rgba(47, 103, 246, 0.14); +} + +.oclm-password-wrap { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} + +.oclm-eye-button { + min-width: 52px; + padding: 0 12px; +} + +.oclm-eye-button svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 2; +} + +.oclm-hint { + margin-top: 6px; + color: var(--oclm-subtle); + font-size: 12px; + line-height: 1.45; +} + +.oclm-toggle { + position: relative; + width: 48px; + height: 28px; + display: inline-flex; + align-items: center; +} + +.oclm-toggle input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; +} + +.oclm-toggle-track { + width: 48px; + height: 28px; + border-radius: 999px; + background: #d5dbe8; + box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08); + position: relative; + transition: background-color .16s ease; +} + +.oclm-app[data-darkmode="true"] .oclm-toggle-track { + background: #334155; +} + +.oclm-toggle-track::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 22px; + height: 22px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0,0,0,.22); + transition: transform .16s ease; +} + +.oclm-toggle input:checked + .oclm-toggle-track { + background: var(--oclm-primary); +} + +.oclm-toggle input:checked + .oclm-toggle-track::after { + transform: translateX(20px); +} + +.oclm-inline-row { + display: flex; + align-items: center; + gap: 10px; +} + +.oclm-address-row { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.oclm-address-text { + min-width: 0; + word-break: break-all; +} + +.oclm-icon-button { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: 1px solid var(--oclm-border); + border-radius: 8px; + background: transparent; + color: var(--oclm-subtle); + cursor: pointer; + flex: 0 0 26px; +} + +.oclm-icon-button:hover { + color: var(--oclm-primary); + border-color: rgba(47, 103, 246, 0.24); +} + +.oclm-icon-button.is-copied { + color: var(--oclm-success); + border-color: rgba(18, 185, 129, 0.35); + background: rgba(18, 185, 129, 0.08); +} + +.oclm-copy-icon { + width: 14px; + height: 14px; +} + +.oclm-origin-list { + display: grid; + gap: 10px; +} + +.oclm-origin-item, +.oclm-origin-new { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; +} + +.oclm-origin-item button, +.oclm-origin-new button { + min-width: 64px; +} + +.oclm-section-submit { + margin-top: 8px; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.oclm-applied-hint { + color: var(--oclm-subtle); + font-size: 12px; +} + +.oclm-banner { + color: var(--oclm-danger); + margin-bottom: 12px; +} + +.oclm-hidden { + display: none !important; +} + +.oclm-log-modal { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(15, 23, 42, 0.42); +} + +.oclm-log-dialog { + width: min(860px, 100%); + max-height: 82vh; + display: flex; + flex-direction: column; + border-radius: 18px; + background: var(--oclm-card); + border: 1px solid var(--oclm-border); + box-shadow: var(--oclm-shadow); + overflow: hidden; +} + +.oclm-log-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--oclm-border); +} + +.oclm-log-head h3 { + margin: 0; + font-size: 15px; +} + +.oclm-log-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.oclm-log-body { + padding: 0; + overflow: auto; + background: #0f172a; +} + +.oclm-log-body pre { + margin: 0; + padding: 16px 18px; + color: #e2e8f0; + font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: pre-wrap; + word-break: break-word; +} + +@media (max-width: 720px) { + .oclm-root { + padding: 14px; + } + + .oclm-header h1 { + font-size: 24px; + } + + .oclm-status-grid, + .oclm-field { + grid-template-columns: 1fr; + gap: 8px; + } + + .oclm-log-modal { + padding: 12px; + } + + .oclm-label { + padding-top: 0; + } +} diff --git a/luci-app-openclawmgr/htdocs/luci-static/resources/openclawmgr/app.js b/luci-app-openclawmgr/htdocs/luci-static/resources/openclawmgr/app.js new file mode 100644 index 00000000..1e55e1df --- /dev/null +++ b/luci-app-openclawmgr/htdocs/luci-static/resources/openclawmgr/app.js @@ -0,0 +1,798 @@ +(function() { + var config = window.openclawmgrConfig || {}; + var container = document.getElementById("openclawmgr-app"); + if (!container) { + return; + } + + function parseRgb(value) { + var m = value && value.match(/\d+/g); + if (m && m.length >= 3) { + return { r: parseInt(m[0], 10), g: parseInt(m[1], 10), b: parseInt(m[2], 10) }; + } + return null; + } + + function detectDarkMode() { + try { + var bg = window.getComputedStyle(document.body).backgroundColor; + var rgb = parseRgb(bg); + if (rgb) { + var luminance = 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b; + return luminance < 128; + } + } catch (e) {} + return false; + } + + var root = container.attachShadow ? (container.shadowRoot || container.attachShadow({ mode: "open" })) : container; + var state = { + status: null, + form: null, + options: null, + newOrigin: "", + activeTab: "basic", + savingSection: "", + lastAppliedAt: "", + showLogModal: false, + logText: "", + logTimer: null, + statusTimer: null, + installWatchTimer: null + }; + var styleText = ""; + + function request(url, options) { + options = options || {}; + options.credentials = "same-origin"; + options.headers = options.headers || {}; + if (config.token) { + options.headers["X-LuCI-Token"] = config.token; + } + return fetch(url, options).then(function(r) { return r.json(); }); + } + + function postJson(url, payload) { + payload = payload || {}; + if (config.token && payload.token == null) { + payload.token = config.token; + } + return request(url, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(payload) + }); + } + + function postForm(url, payload) { + var body = new URLSearchParams(); + payload = payload || {}; + if (config.token && payload.token == null) { + payload.token = config.token; + } + Object.keys(payload || {}).forEach(function(key) { + body.append(key, payload[key]); + }); + return request(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" + }, + body: body.toString() + }); + } + + function escapeHtml(value) { + return String(value == null ? "" : value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function modelForAgent(agent) { + return { + openai: "openai/gpt-5.2", + anthropic: "anthropic/claude-sonnet-4-6", + "minimax-cn": "minimax-cn/MiniMax-M2.5", + moonshot: "moonshot/kimi-k2.5" + }[agent] || "anthropic/claude-sonnet-4-6"; + } + + function modelMatchesAgent(agent, model) { + model = String(model || ""); + return { + openai: /^openai\//, + anthropic: /^anthropic\//, + "minimax-cn": /^minimax-cn\//, + moonshot: /^moonshot\// + }[agent] ? ({ + openai: /^openai\//, + anthropic: /^anthropic\//, + "minimax-cn": /^minimax-cn\//, + moonshot: /^moonshot\// + }[agent]).test(model) : false; + } + + function resolveModelValue(form) { + var agent = form && form.default_agent ? form.default_agent : "anthropic"; + var value = form && form.default_model ? String(form.default_model) : ""; + if (!value || !modelMatchesAgent(agent, value)) { + return modelForAgent(agent); + } + return value; + } + + function statusText(status) { + if (status.installing) return "安装中"; + if (!status || !status.installed) return "未安装"; + if (status.running) return "运行中"; + return "已停止"; + } + + function statusDotClass(status) { + if (status && status.installing) return ""; + if (status && status.running) return "is-success"; + return "is-danger"; + } + + function statusSpinSeconds(status) { + return (backgroundStatusDelay(status) / 1000).toFixed(1); + } + + function escapeAttr(value) { + return escapeHtml(value).replace(/'/g, "'"); + } + + function maskedTokenUrl(url) { + var value = String(url || ""); + if (!value) { + return "-"; + } + return value.replace(/(#token=)([^&#]+)/, function(_, prefix, token) { + if (!token) { + return prefix; + } + var head = token.slice(0, 6); + var tail = token.length > 10 ? token.slice(-4) : ""; + return prefix + head + "****" + tail; + }); + } + + function copyIcon(className) { + return '' + + ''; + } + + function copiedIcon(className) { + return '' + + ''; + } + + function fallbackCopyText(value) { + var el = document.createElement("textarea"); + el.value = String(value || ""); + el.setAttribute("readonly", "readonly"); + el.style.position = "fixed"; + el.style.top = "-9999px"; + el.style.left = "-9999px"; + document.body.appendChild(el); + el.focus(); + el.select(); + var ok = false; + try { + ok = document.execCommand("copy"); + } catch (e) {} + document.body.removeChild(el); + return ok; + } + + function copyText(value) { + value = String(value || ""); + if (!value) { + return Promise.resolve(false); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(value).then(function() { + return true; + }).catch(function() { + return fallbackCopyText(value); + }); + } + return Promise.resolve(fallbackCopyText(value)); + } + + function flashCopied(el) { + if (!el) { + return; + } + if (el._oclmCopyTimer) { + window.clearTimeout(el._oclmCopyTimer); + el._oclmCopyTimer = null; + } + el.classList.add("is-copied"); + el.innerHTML = copiedIcon("oclm-copy-icon"); + el._oclmCopyTimer = window.setTimeout(function() { + el.classList.remove("is-copied"); + el.innerHTML = copyIcon("oclm-copy-icon"); + el._oclmCopyTimer = null; + }, 1100); + } + + function openclawIcon(className) { + return '' + + ''; + } + + function openLogModal() { + state.showLogModal = true; + if (state.logTimer) { + window.clearTimeout(state.logTimer); + state.logTimer = null; + } + pollLogs(); + render(); + } + + function closeLogModal() { + state.showLogModal = false; + if (state.logTimer) { + window.clearTimeout(state.logTimer); + state.logTimer = null; + } + refreshStatus(); + } + + function pollLogs() { + if (!state.showLogModal) { + return; + } + if (state.logTimer) { + window.clearTimeout(state.logTimer); + state.logTimer = null; + } + request(config.logUrl + "?n=200").then(function(data) { + state.logText = (data && data.log) || ""; + refreshStatus(function() { + if (state.showLogModal) { + state.logTimer = window.setTimeout(pollLogs, 3000); + } + }); + }).catch(function() { + if (state.showLogModal) { + state.logTimer = window.setTimeout(pollLogs, 5000); + } + }); + } + + function stopStatusPolling() { + if (state.statusTimer) { + window.clearTimeout(state.statusTimer); + state.statusTimer = null; + } + } + + function stopInstallWatch() { + if (state.installWatchTimer) { + window.clearTimeout(state.installWatchTimer); + state.installWatchTimer = null; + } + } + + function backgroundStatusDelay(status) { + if (status && status.installing) { + return 3000; + } + if (!status || !status.installed) { + return 30000; + } + if (status.running) { + return 15000; + } + return 20000; + } + + function ensureInstallWatch() { + stopInstallWatch(); + if (state.status && state.status.installing && state.showLogModal) { + return; + } + state.installWatchTimer = window.setTimeout(function() { + refreshStatus(function() { + ensureInstallWatch(); + }); + }, backgroundStatusDelay(state.status)); + } + + function scheduleStatusRefresh(rounds, delay) { + rounds = typeof rounds === "number" ? rounds : 6; + delay = typeof delay === "number" ? delay : 1000; + stopStatusPolling(); + + function tick(remaining) { + refreshStatus(function() { + if (remaining > 1) { + state.statusTimer = window.setTimeout(function() { + tick(remaining - 1); + }, delay); + } else { + state.statusTimer = null; + } + }); + } + + tick(rounds); + } + + function scrollLogToBottom() { + if (!state.showLogModal) { + return; + } + var body = root.querySelector(".oclm-log-body"); + if (body) { + body.scrollTop = body.scrollHeight; + } + } + + function render() { + var status = state.status || {}; + var form = state.form || {}; + var options = state.options || { base_dir_choices: [] }; + var allowedOrigins = Array.isArray(form.allowed_origins) ? form.allowed_origins : []; + var baseDirOptions = (options.base_dir_choices || []).map(function(path) { + return ''; + }).join(""); + var origins = allowedOrigins.map(function(item, index) { + return '' + + '
' + + '' + + '' + + '
'; + }).join(""); + + var installLabel = status.installing ? "安装中" : "立即安装"; + var installAcceleratedChecked = form.install_accelerated == null ? true : form.install_accelerated === true; + var showInstallAction = !status.installed || status.installing; + var showServiceActions = status.installed && !status.installing; + var activeTab = state.activeTab || "basic"; + var savingBasic = state.savingSection === "basic"; + var savingAccess = state.savingSection === "access"; + + root.innerHTML = + '' + + '
' + + '
' + + '
' + + '
' + + '

' + openclawIcon("oclm-title-icon") + 'OpenClaw 启动器

' + + '

在 OpenWrt 上原生安装、启动并管理官方 OpenClaw

' + + '
' + + + '
' + + '

服务状态

' + + '
' + + '
状态' + escapeHtml(statusText(status)) + '' + (status.running && status.uptime_human ? ('运行时间 ' + escapeHtml(status.uptime_human) + '') : '') + '
' + + '
' + ((status.running && status.pid) ? ('
PID ' + escapeHtml(status.pid) + '
') : '') + '
' + + '
地址
' + escapeHtml(maskedTokenUrl(status.token_url || status.base_url || "")) + '' + ((status.token_url || status.base_url) ? ('') : '') + '
' + + '
目录
' + escapeHtml(status.base_dir || "-") + '
' + + '
版本
OpenClaw ' + escapeHtml(status.openclaw_version || "-") + 'Node ' + escapeHtml(status.node_version || "-") + '
' + + '
' + + '
' + + '
' + + (showInstallAction ? '
' : '') + + (status.running ? '' + openclawIcon("oclm-button-icon") + '打开控制台' : '') + + (!status.running && showServiceActions ? '' : '') + + (status.running && showServiceActions ? '' : '') + + (showServiceActions ? '' : '') + + (status.installing ? '' : '') + + '
' + + (status.installing ? '
安装任务正在后台运行,点击“安装中”可继续查看日志。
' : '') + + '
' + + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '

基础配置

' + + '
' + + fieldToggle("启用服务", "enabled", form.enabled) + + fieldInput("监听端口", '') + + fieldInput("监听范围", selectHtml("oclm-bind", form.bind, [ + ["lan", "所有地址"], + ["loopback", "仅本机"], + ["auto", "自动"] + ])) + + fieldInput("数据目录", '' + baseDirOptions + '') + + fieldInput("默认服务提供商", selectHtml("oclm-agent", form.default_agent, [ + ["openai", "OpenAI"], + ["anthropic", "Anthropic"], + ["minimax-cn", "MiniMax CN"], + ["moonshot", "Moonshot CN"] + ])) + + fieldInput("API 密钥", passwordHtml("oclm-api-key", form.provider_api_key || "", "sk-...")) + + fieldInput("中转地址(可选)", '') + + fieldInput("默认模型", '') + + '
' + (state.lastAppliedAt ? '已于 ' + escapeHtml(state.lastAppliedAt) + ' 更新配置' : '') + '
' + + '
' + + + '
' + + '

访问控制

' + + '
' + + fieldInput("访问令牌", passwordHtml("oclm-token", form.token || "", "")) + + fieldInput("允许访问来源", '
' + origins + + '
' + + '
展示:仅允许来源于该地址访问控制台的控制功能
') + + fieldToggle("允许通过 HTTP 访问时认证", "allow_insecure_auth", form.allow_insecure_auth, "仅控制 HTTP 下的控制台认证,不影响端口监听") + + fieldToggle("关闭设备身份校验", "disable_device_auth", form.disable_device_auth, "警告:仅建议在可信环境中开启,在内网生效") + + '
' + (state.lastAppliedAt ? '已于 ' + escapeHtml(state.lastAppliedAt) + ' 更新配置' : '') + '
' + + '
' + + + '
' + + '

卸载清理

' + + '
以下操作会影响当前安装环境,请在确认后执行。
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + + (state.showLogModal ? ( + '
' + + '
' + + '
' + + '

安装日志

' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + escapeHtml(state.logText || "日志为空") + '
' + + '
' + + '
' + ) : '') + + + '
'; + + bindEvents(); + scrollLogToBottom(); + } + + function fieldInput(label, control) { + return '
' + label + '
' + control + '
'; + } + + function fieldToggle(label, key, checked, hint) { + return '' + + '
' + + '
' + label + '
' + + '
' + + (hint ? '
' + hint + '
' : '') + + '
'; + } + + function selectHtml(id, value, items) { + return ''; + } + + function passwordHtml(id, value, placeholder) { + return '' + + '
' + + '' + + '' + + '
'; + } + + function eyeIcon(off) { + if (off) { + return '' + + ''; + } + return '' + + ''; + } + + function bindEvents() { + Array.prototype.forEach.call(root.querySelectorAll("[data-tab]"), function(el) { + el.onclick = function() { + state.activeTab = el.getAttribute("data-tab") || "basic"; + render(); + }; + }); + + Array.prototype.forEach.call(root.querySelectorAll("[data-op]"), function(el) { + el.onclick = function() { + var op = el.getAttribute("data-op"); + if (!op) return; + if (op === "uninstall_openclaw" && !window.confirm("卸载 OpenClaw 运行时但保留 Node.js,确认继续?")) return; + if (op === "uninstall" && !window.confirm("卸载将删除运行时,但保留数据目录。确认继续?")) return; + if (op === "purge" && !window.confirm("彻底清理会删除运行时和数据目录。确认继续?")) return; + postForm(config.opUrl, { op: op }).then(function(rv) { + if (!rv || !rv.ok) { + window.alert((rv && rv.error) || "操作失败"); + return; + } + scheduleStatusRefresh(op === "restart" ? 8 : 6, 1000); + }); + }; + }); + + Array.prototype.forEach.call(root.querySelectorAll("[data-install-action]"), function(el) { + el.onclick = function() { + openLogModal(); + var accelerated = !!(root.getElementById("oclm-install-accelerated") && root.getElementById("oclm-install-accelerated").checked); + if (!state.status || state.status.installing) { + return; + } + state.status.installing = true; + state.status.task_running = true; + state.status.task_op = "install"; + render(); + postJson(config.configUrl, { install_accelerated: accelerated }).then(function(cfgRv) { + if (!cfgRv || !cfgRv.ok) { + state.status.installing = false; + render(); + window.alert((cfgRv && cfgRv.error) || "保存安装选项失败"); + return; + } + state.form.install_accelerated = accelerated; + return postForm(config.opUrl, { op: "install" }); + }).then(function(rv) { + if (!rv) return; + if (!rv || !rv.ok) { + state.status.installing = false; + render(); + window.alert((rv && rv.error) || "启动安装失败"); + return; + } + scheduleStatusRefresh(10, 1000); + }).catch(function() { + state.status.installing = false; + render(); + window.alert("启动安装失败"); + }); + }; + }); + + Array.prototype.forEach.call(root.querySelectorAll("[data-close-log], [data-close-log-btn]"), function(el) { + el.onclick = function() { + closeLogModal(); + }; + }); + + Array.prototype.forEach.call(root.querySelectorAll("[data-toggle-password]"), function(el) { + el.onclick = function() { + var id = el.getAttribute("data-toggle-password"); + var input = id ? root.getElementById(id) : null; + if (!input) return; + input.type = input.type === "password" ? "text" : "password"; + el.innerHTML = eyeIcon(input.type !== "password"); + el.setAttribute("aria-label", input.type === "password" ? "显示密钥" : "隐藏密钥"); + }; + }); + + var installAccelerated = root.getElementById("oclm-install-accelerated"); + if (installAccelerated) { + installAccelerated.onchange = function() { + state.form.install_accelerated = !!installAccelerated.checked; + }; + } + + Array.prototype.forEach.call(root.querySelectorAll("[data-refresh-log]"), function(el) { + el.onclick = function() { + pollLogs(); + }; + }); + + Array.prototype.forEach.call(root.querySelectorAll("[data-copy-log]"), function(el) { + el.onclick = function() { + copyText(state.logText || ""); + }; + }); + + Array.prototype.forEach.call(root.querySelectorAll("[data-copy-token-url]"), function(el) { + el.onclick = function() { + var value = (state.status && (state.status.token_url || state.status.base_url)) || ""; + copyText(value).then(function(ok) { + if (ok) { + flashCopied(el); + } + }); + }; + }); + + var agent = root.getElementById("oclm-agent"); + if (agent) { + agent.onchange = function() { + state.form.default_agent = agent.value; + var model = root.getElementById("oclm-model"); + if (model) { + var nextModel = modelForAgent(agent.value); + model.value = nextModel; + state.form.default_model = nextModel; + } + }; + } + + var addOrigin = root.getElementById("oclm-add-origin"); + if (addOrigin) { + addOrigin.onclick = function() { + var input = root.getElementById("oclm-new-origin"); + var value = input && input.value ? input.value.trim() : ""; + if (!value) return; + state.form.allowed_origins = Array.isArray(state.form.allowed_origins) ? state.form.allowed_origins : []; + state.form.allowed_origins.push(value); + state.newOrigin = ""; + render(); + }; + } + + Array.prototype.forEach.call(root.querySelectorAll("[data-remove-origin]"), function(el) { + el.onclick = function() { + var index = parseInt(el.getAttribute("data-remove-origin"), 10); + if (isNaN(index)) return; + state.form.allowed_origins.splice(index, 1); + render(); + }; + }); + + Array.prototype.forEach.call(root.querySelectorAll("[data-origin-index]"), function(el) { + el.oninput = function() { + var index = parseInt(el.getAttribute("data-origin-index"), 10); + if (!isNaN(index)) { + state.form.allowed_origins[index] = el.value; + } + }; + }); + + var saveBasic = root.getElementById("oclm-save-basic"); + if (saveBasic) { + saveBasic.onclick = function() { + var payload = { + enabled: !!root.getElementById("oclm-enabled").checked, + port: root.getElementById("oclm-port").value, + bind: root.getElementById("oclm-bind").value, + base_dir: root.getElementById("oclm-base-dir").value, + default_agent: root.getElementById("oclm-agent").value, + default_model: root.getElementById("oclm-model").value, + install_accelerated: !!(root.getElementById("oclm-install-accelerated") && root.getElementById("oclm-install-accelerated").checked), + provider_api_key: root.getElementById("oclm-api-key").value, + provider_base_url: root.getElementById("oclm-base-url").value + }; + state.savingSection = "basic"; + render(); + postJson(config.configUrl, payload).then(function(rv) { handleSaveResult(rv, "basic"); }).catch(function() { + state.savingSection = ""; + render(); + window.alert("保存失败"); + }); + }; + } + + var saveAccess = root.getElementById("oclm-save-access"); + if (saveAccess) { + saveAccess.onclick = function() { + var payload = { + token: root.getElementById("oclm-token").value, + allowed_origins: (Array.isArray(state.form.allowed_origins) ? state.form.allowed_origins : []).map(function(item) { return item.trim(); }).filter(Boolean), + allow_insecure_auth: !!root.getElementById("oclm-allow_insecure_auth").checked, + disable_device_auth: !!root.getElementById("oclm-disable_device_auth").checked + }; + state.savingSection = "access"; + render(); + postJson(config.configUrl, payload).then(function(rv) { handleSaveResult(rv, "access"); }).catch(function() { + state.savingSection = ""; + render(); + window.alert("保存失败"); + }); + }; + } + } + + function handleSaveResult(rv, section) { + if (!rv || !rv.ok) { + state.savingSection = ""; + render(); + window.alert((rv && rv.error) || "保存失败"); + return; + } + postForm(config.applyUrl, {}).then(function(applyRv) { + state.savingSection = ""; + if (!applyRv || !applyRv.ok) { + render(); + window.alert((applyRv && applyRv.error) || "应用配置失败"); + loadConfig(); + scheduleStatusRefresh(3, 600); + return; + } + state.lastAppliedAt = applyRv.applied_at || ""; + render(); + loadConfig(); + scheduleStatusRefresh(6, 800); + }).catch(function() { + state.savingSection = ""; + render(); + window.alert("应用配置失败"); + loadConfig(); + scheduleStatusRefresh(3, 600); + }); + } + + function refreshStatus(done) { + request(config.statusUrl).then(function(data) { + state.status = data || {}; + render(); + ensureInstallWatch(); + if (typeof done === "function") { + done(); + } + }).catch(function() { + state.status = state.status || {}; + render(); + ensureInstallWatch(); + if (typeof done === "function") { + done(); + } + }); + } + + function loadConfig() { + request(config.configUrl).then(function(data) { + if (!data || !data.ok) { + return; + } + state.form = data.config || {}; + state.form.default_model = resolveModelValue(state.form); + state.options = data.options || {}; + render(); + refreshStatus(); + }).catch(function() { + state.form = state.form || {}; + state.options = state.options || { base_dir_choices: [] }; + render(); + }); + } + + fetch(config.staticBase + "/app.css").then(function(r) { return r.text(); }).then(function(css) { + styleText = css || ""; + loadConfig(); + }); +})(); diff --git a/luci-app-openclawmgr/htdocs/luci-static/resources/openclawmgr/openclaw.svg b/luci-app-openclawmgr/htdocs/luci-static/resources/openclawmgr/openclaw.svg new file mode 100644 index 00000000..ca4c3e55 --- /dev/null +++ b/luci-app-openclawmgr/htdocs/luci-static/resources/openclawmgr/openclaw.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/luci-app-openclawmgr/luasrc/controller/openclawmgr.lua b/luci-app-openclawmgr/luasrc/controller/openclawmgr.lua new file mode 100644 index 00000000..45f372f6 --- /dev/null +++ b/luci-app-openclawmgr/luasrc/controller/openclawmgr.lua @@ -0,0 +1,784 @@ +module("luci.controller.openclawmgr", package.seeall) + +function index() + local fs = require "nixio.fs" + if not fs.access("/etc/config/openclawmgr") then + return + end + + entry({"admin", "services", "openclawmgr"}, alias("admin", "services", "openclawmgr", "config"), _("OpenClaw 启动器"), 90).dependent = true + local page = entry({"admin", "services", "openclawmgr", "config"}, call("action_app"), _("Config"), 10) + page.leaf = true + + entry({"admin", "services", "openclawmgr", "status"}, call("action_status")).leaf = true + entry({"admin", "services", "openclawmgr", "op"}, call("action_op")).leaf = true + entry({"admin", "services", "openclawmgr", "config_data"}, call("action_config_data")).leaf = true + entry({"admin", "services", "openclawmgr", "apply_config"}, call("action_apply_config")).leaf = true + entry({"admin", "services", "openclawmgr", "log"}, call("action_log")).leaf = true + + entry({"admin", "services", "openclawmgr", "diag_info"}, call("action_diag_info")).leaf = true + entry({"admin", "services", "openclawmgr", "diag_run"}, call("action_diag_run")).leaf = true + entry({"admin", "services", "openclawmgr", "diag_poll"}, call("action_diag_poll")).leaf = true +end + +function action_app() + local http = require "luci.http" + local tmpl = require "luci.template" + local disp = require "luci.dispatcher" + local ctx = disp.context or {} + + tmpl.render("openclawmgr/app", { + token = ctx.token or "", + }) + http.close() +end + +local function lan_ipv4() + local sys = require "luci.sys" + local jsonc = require "luci.jsonc" + local raw = sys.exec("ubus call network.interface.lan status 2>/dev/null") + local obj = jsonc.parse(raw) + if obj and obj["ipv4-address"] then + for _, addr in ipairs(obj["ipv4-address"]) do + if addr.address and addr.address ~= "" then + return addr.address + end + end + end + return "" +end + +local function default_allowed_origin(port_val) + local ip = lan_ipv4() + if ip ~= "" then + return "http://" .. ip .. ":" .. port_val + end + return "" +end + +local function write_json(obj) + local http = require "luci.http" + http.prepare_content("application/json") + http.write_json(obj) +end + +local function read_json_body() + local http = require "luci.http" + local jsonc = require "luci.jsonc" + local ctype = http.getenv("CONTENT_TYPE") or "" + if not ctype:match("^application/json") then + return nil + end + local raw = http.content() or "" + if #raw == 0 then + return nil + end + local obj = jsonc.parse(raw) + if type(obj) ~= "table" then + return nil + end + return obj +end + +local function get_task_state(task_id) + local fs = require "nixio.fs" + local jsonc = require "luci.jsonc" + local sys = require "luci.sys" + + if not fs.access("/etc/init.d/tasks") then + return { + running = false, + op = "", + command = "", + } + end + + local raw = sys.exec("/etc/init.d/tasks task_status " .. task_id .. " 2>/dev/null") + local obj = jsonc.parse(raw) or {} + local cmd = "" + local cmd_parts = {} + + local function as_bool(v) + return v == true or v == "true" or v == "1" or v == 1 + end + + if type(obj.command) == "table" then + cmd_parts = obj.command + cmd = table.concat(obj.command, " ") + elseif type(obj.command) == "string" then + cmd = obj.command + elseif type(obj.data) == "table" and type(obj.data.command) == "string" then + cmd = obj.data.command + end + + local op = "" + for _, candidate in ipairs({ "install", "upgrade", "uninstall_openclaw", "uninstall", "purge", "restart", "start", "stop" }) do + if cmd:match("(^|[^%w_])" .. candidate .. "([^%w_]|$)") then + op = candidate + break + end + for _, part in ipairs(cmd_parts) do + if type(part) == "string" and part:match("(^|[^%w_])" .. candidate .. "([^%w_]|$)") then + op = candidate + break + end + end + if op ~= "" then + break + end + end + + return { + running = as_bool(obj.running), + op = op, + command = cmd, + pid = obj.pid and tostring(obj.pid) or "", + } +end + +local function require_csrf() + local http = require "luci.http" + local disp = require "luci.dispatcher" + + local method = http.getenv("REQUEST_METHOD") or "" + if method ~= "POST" then + return true + end + + local ctx = disp.context + if not (ctx and ctx.authsession) then + write_json({ ok = false, error = "auth session missing" }) + return false + end + + local expected = ctx.token + local header_token = http.getenv("HTTP_X_LUCI_TOKEN") + local form_token = http.formvalue("token") + local body = read_json_body() + local body_token = (type(body) == "table") and body["token"] or nil + local provided = header_token or form_token or body_token + + if expected and provided ~= expected then + write_json({ ok = false, error = "bad csrf token" }) + return false + end + if not expected and (not provided or #provided == 0) then + write_json({ ok = false, error = "csrf token missing" }) + return false + end + return true +end + +local function get_host() + local http = require "luci.http" + local host = http.getenv("HTTP_HOST") or http.getenv("SERVER_NAME") or "" + host = host:gsub(":%d+$", "") + if host == "_redirect2ssl" or host == "redirect2ssl" or host == "" then + host = http.getenv("SERVER_ADDR") or "localhost" + end + return host +end + +local function configured_base_path(base_dir) + local jsonc = require "luci.jsonc" + local path = (base_dir or "/opt/openclawmgr") .. "/data/.openclaw/openclaw.json" + local f = io.open(path, "r") + if not f then + return "/" + end + + local raw = f:read("*a") or "" + f:close() + + local obj = jsonc.parse(raw) + local base_path = obj + and obj.gateway + and obj.gateway.controlUi + and obj.gateway.controlUi.basePath + if type(base_path) ~= "string" or base_path == "" or base_path == "/" then + return "/" + end + if base_path:sub(1, 1) ~= "/" then + base_path = "/" .. base_path + end + return base_path:gsub("/+$", "") .. "/" +end + +local function fmt_elapsed(seconds) + seconds = tonumber(seconds) or 0 + if seconds < 60 then + return string.format("%ds", seconds) + elseif seconds < 3600 then + return string.format("%dm%02ds", math.floor(seconds / 60), seconds % 60) + elseif seconds < 86400 then + return string.format("%dh%02dm", math.floor(seconds / 3600), math.floor((seconds % 3600) / 60)) + end + return string.format("%dd%02dh", math.floor(seconds / 86400), math.floor((seconds % 86400) / 3600)) +end + +local function get_running_pid() + local sys = require "luci.sys" + local jsonc = require "luci.jsonc" + local raw = sys.exec("ubus call service list '{\"name\":\"openclawmgr\"}' 2>/dev/null") + local obj = jsonc.parse(raw) or {} + local svc = obj.openclawmgr + if type(svc) ~= "table" or type(svc.instances) ~= "table" then + return "" + end + local inst = svc.instances.gateway + if type(inst) == "table" and inst.pid then + return tostring(inst.pid) + end + return "" +end + +local function installer_lock_pid() + local f = io.open("/tmp/openclawmgr-installer.lock/pid", "r") + if not f then + return "" + end + local pid = (f:read("*l") or ""):gsub("%s+$", "") + f:close() + return pid +end + +local function installer_lock_running() + local sys = require "luci.sys" + local pid = installer_lock_pid() + if pid ~= "" and sys.call("kill -0 " .. pid .. " >/dev/null 2>&1") == 0 then + local f = io.open("/proc/" .. pid .. "/cmdline", "r") + local cmdline = "" + if f then + cmdline = (f:read("*a") or ""):gsub("%z", " ") + f:close() + end + if cmdline:match("openclawmgr%.sh") and (cmdline:match(" install([%s]|$)") or cmdline:match(" upgrade([%s]|$)")) then + return true, pid + end + end + return false, pid +end + +local function get_pid_uptime_human(pid) + if not pid or pid == "" then + return "" + end + local f = io.open("/proc/" .. pid .. "/stat", "r") + if not f then + return "" + end + local stat_line = f:read("*l") or "" + f:close() + + local uf = io.open("/proc/uptime", "r") + if not uf then + return "" + end + local uptime_line = uf:read("*l") or "" + uf:close() + + local after = stat_line:match("%) (.+)$") + if not after then + return "" + end + local fields = {} + for part in after:gmatch("%S+") do + fields[#fields + 1] = part + end + local start_ticks = tonumber(fields[20] or "") + local system_uptime = tonumber((uptime_line:match("^(%S+)"))) or 0 + if not start_ticks or system_uptime <= 0 then + return "" + end + + local ticks_per_sec = 100 + local elapsed = math.floor(system_uptime - (start_ticks / ticks_per_sec)) + if elapsed < 0 then + elapsed = 0 + end + return fmt_elapsed(elapsed) +end + +function action_status() + local sys = require "luci.sys" + local uci = require "luci.model.uci".cursor() + local task = get_task_state("openclawmgr") + + local enabled = uci:get("openclawmgr", "main", "enabled") or "0" + local port = uci:get("openclawmgr", "main", "port") or "18789" + local bind = uci:get("openclawmgr", "main", "bind") or "lan" + local base_dir = uci:get("openclawmgr", "main", "base_dir") or "/opt/openclawmgr" + local token = uci:get("openclawmgr", "main", "token") or "" + + if not port:match("^%d+$") then port = "18789" end + + local st = sys.exec("/usr/libexec/istorec/openclawmgr.sh status 2>/dev/null"):gsub("%s+$", "") + local running = (st == "running") + local installed = (st == "running" or st == "stopped") + local lock_running, lock_pid = installer_lock_running() + local installing = (task.running and (task.op == "install" or task.op == "upgrade")) or lock_running + if not installing and task.running and not installed and not running then + installing = true + end + + local node_ver = sys.exec("/usr/libexec/istorec/openclawmgr.sh node_version 2>/dev/null"):gsub("%s+$", "") + local oc_ver = sys.exec("/usr/libexec/istorec/openclawmgr.sh openclaw_version 2>/dev/null"):gsub("%s+$", "") + local pid = running and get_running_pid() or "" + if pid == "" and installing and lock_pid ~= "" then + pid = lock_pid + end + local uptime_human = running and get_pid_uptime_human(pid) or "" + + local base_url = "http://" .. get_host() .. ":" .. port .. configured_base_path(base_dir) + local token_url = base_url + if token ~= "" then + token_url = token_url .. "#token=" .. token + end + + write_json({ + ok = true, + enabled = enabled, + installed = installed, + running = running, + task_running = task.running, + task_op = task.op, + installing = installing, + port = port, + bind = bind, + base_dir = base_dir, + token = token, + node_version = node_ver, + openclaw_version = oc_ver, + pid = pid, + uptime_human = uptime_human, + base_url = base_url, + token_url = token_url, + url = token_url, + }) +end + +function action_log() + local sys = require "luci.sys" + local util = require "luci.util" + local uci = require "luci.model.uci".cursor() + local base_dir = uci:get("openclawmgr", "main", "base_dir") or "/opt/openclawmgr" + local log_file = base_dir .. "/log/installer.log" + + local n = tonumber((require "luci.http").formvalue("n") or "") or 200 + if n < 10 then n = 10 end + if n > 2000 then n = 2000 end + + local content = sys.exec("tail -n " .. n .. " " .. util.shellquote(log_file) .. " 2>/dev/null") or "" + write_json({ ok = true, log = content }) +end + +function action_apply_config() + local sys = require "luci.sys" + + if not require_csrf() then + return + end + + local rc = sys.call("/usr/libexec/istorec/openclawmgr.sh apply_config >/dev/null 2>&1") + if rc ~= 0 then + write_json({ ok = false, error = "apply config failed" }) + return + end + + write_json({ + ok = true, + applied_at = os.date("%Y-%m-%d %H:%M:%S"), + }) +end + +function action_config_data() + local http = require "luci.http" + local jsonc = require "luci.jsonc" + local uci = require "luci.model.uci".cursor() + local model = require "luci.model.openclawmgr" + + if (http.getenv("REQUEST_METHOD") or "GET") == "POST" then + if not require_csrf() then + return + end + + local body = read_json_body() or {} + local section = "main" + + local function bool_to_uci(value) + return value and "1" or "0" + end + + local function has(key) + return body[key] ~= nil + end + + if has("enabled") then + uci:set("openclawmgr", section, "enabled", bool_to_uci(body.enabled == true or body.enabled == "1")) + end + + if has("port") then + local port = tostring(body.port or "") + if not port:match("^%d+$") then + write_json({ ok = false, error = "invalid port" }) + return + end + local port_num = tonumber(port) or 0 + if port_num < 1 or port_num > 65535 then + write_json({ ok = false, error = "invalid port" }) + return + end + uci:set("openclawmgr", section, "port", port) + end + + if has("bind") then + local bind = tostring(body.bind or "") + if bind ~= "loopback" and bind ~= "lan" and bind ~= "auto" and bind ~= "tailnet" and bind ~= "custom" then + write_json({ ok = false, error = "invalid bind" }) + return + end + uci:set("openclawmgr", section, "bind", bind) + end + + if has("base_dir") then + local base_dir = tostring(body.base_dir or "") + if base_dir == "" then + write_json({ ok = false, error = "base_dir required" }) + return + end + uci:set("openclawmgr", section, "base_dir", base_dir) + end + + if has("default_agent") then + local agent = tostring(body.default_agent or "") + if agent ~= "openai" and agent ~= "anthropic" and agent ~= "minimax-cn" and agent ~= "moonshot" then + write_json({ ok = false, error = "invalid default_agent" }) + return + end + uci:set("openclawmgr", section, "default_agent", agent) + end + + if has("default_model") then + uci:set("openclawmgr", section, "default_model", tostring(body.default_model or "")) + end + + if has("install_accelerated") then + uci:set("openclawmgr", section, "install_accelerated", bool_to_uci(body.install_accelerated == true or body.install_accelerated == "1")) + end + + if has("provider_api_key") then + uci:set("openclawmgr", section, "provider_api_key", tostring(body.provider_api_key or "")) + end + + if has("provider_base_url") then + local value = tostring(body.provider_base_url or "") + if value ~= "" and not value:match("^https?://") then + write_json({ ok = false, error = "invalid provider_base_url" }) + return + end + uci:set("openclawmgr", section, "provider_base_url", value) + end + + if has("token") then + uci:set("openclawmgr", section, "token", tostring(body.token or "")) + end + + if has("allowed_origins") then + local origins = {} + if type(body.allowed_origins) == "table" then + for _, item in ipairs(body.allowed_origins) do + item = tostring(item or ""):match("^%s*(.-)%s*$") + if item and item ~= "" then + table.insert(origins, item) + end + end + end + if #origins > 0 then + uci:set_list("openclawmgr", section, "allowed_origins", origins) + else + uci:delete("openclawmgr", section, "allowed_origins") + end + end + + if has("allow_insecure_auth") then + uci:set("openclawmgr", section, "allow_insecure_auth", bool_to_uci(body.allow_insecure_auth == true or body.allow_insecure_auth == "1")) + end + + if has("disable_device_auth") then + uci:set("openclawmgr", section, "disable_device_auth", bool_to_uci(body.disable_device_auth == true or body.disable_device_auth == "1")) + end + + uci:commit("openclawmgr") + write_json({ ok = true }) + return + end + + local base_dir = uci:get("openclawmgr", "main", "base_dir") or "/opt/openclawmgr" + local port = uci:get("openclawmgr", "main", "port") or "18789" + local blocks = model.blocks() + local home = model.home() + local paths, default_path = model.find_paths(blocks, home, "Configs") + local choices = {} + local seen = {} + + local function add_choice(path) + if path and path ~= "" and not seen[path] then + table.insert(choices, path) + seen[path] = true + end + end + + add_choice(base_dir) + for _, path in ipairs(paths or {}) do + add_choice(path) + end + add_choice(default_path or "/opt/openclawmgr") + + local allowed_origins = {} + for _, item in ipairs(uci:get_list("openclawmgr", "main", "allowed_origins") or {}) do + allowed_origins[#allowed_origins + 1] = item + end + + write_json({ + ok = true, + config = { + enabled = (uci:get("openclawmgr", "main", "enabled") or "0") == "1", + port = port, + bind = uci:get("openclawmgr", "main", "bind") or "lan", + base_dir = base_dir, + token = uci:get("openclawmgr", "main", "token") or "", + allowed_origins = allowed_origins, + allow_insecure_auth = (uci:get("openclawmgr", "main", "allow_insecure_auth") or "0") == "1", + disable_device_auth = (uci:get("openclawmgr", "main", "disable_device_auth") or "0") == "1", + default_agent = uci:get("openclawmgr", "main", "default_agent") or "anthropic", + default_model = uci:get("openclawmgr", "main", "default_model") or "", + install_accelerated = (uci:get("openclawmgr", "main", "install_accelerated") or "1") == "1", + provider_api_key = uci:get("openclawmgr", "main", "provider_api_key") or "", + provider_base_url = uci:get("openclawmgr", "main", "provider_base_url") or "", + }, + options = { + base_dir_choices = choices, + default_origin = default_allowed_origin(port), + } + }) +end + +function action_op() + local http = require "luci.http" + local i18n = require "luci.i18n" + local sys = require "luci.sys" + local util = require "luci.util" + local fs = require "nixio.fs" + local tasks = nil + do + local ok, mod = pcall(require, "luci.model.tasks") + if ok then tasks = mod end + end + + if not require_csrf() then + return + end + + local op = http.formvalue("op") or "" + local ok_ops = { install = true, upgrade = true, start = true, stop = true, restart = true, apply_config = true, uninstall = true, uninstall_openclaw = true, purge = true, cancel_install = true } + if not ok_ops[op] then + write_json({ ok = false, error = "unknown op" }) + return + end + + local task_id = "openclawmgr" + local script_path = "/usr/libexec/istorec/openclawmgr.sh" + + if op == "cancel_install" then + if not fs.access("/etc/init.d/tasks") then + write_json({ ok = false, error = i18n.translate("taskd is not available") }) + return + end + + local task = get_task_state(task_id) + local st = sys.exec("/usr/libexec/istorec/openclawmgr.sh status 2>/dev/null"):gsub("%s+$", "") + local installed = (st == "running" or st == "stopped") + local install_like = task.running and (task.op == "install" or task.op == "upgrade" or not installed) + if not install_like then + write_json({ ok = false, error = i18n.translate("No install task is running") }) + return + end + + local rc = sys.call("/etc/init.d/tasks task_del " .. task_id .. " >/dev/null 2>&1") + if rc ~= 0 and task.pid ~= "" then + sys.call("kill -TERM " .. util.shellquote(task.pid) .. " >/dev/null 2>&1") + sys.call("sleep 1") + sys.call("kill -KILL " .. util.shellquote(task.pid) .. " >/dev/null 2>&1") + rc = sys.call("/etc/init.d/tasks task_del " .. task_id .. " >/dev/null 2>&1") + end + + if rc == 0 or not get_task_state(task_id).running then + sys.call("rm -rf /tmp/openclawmgr-installer.lock >/dev/null 2>&1") + write_json({ ok = true, canceled = true, task_id = task_id }) + return + end + + write_json({ ok = false, error = i18n.translate("Failed to stop installation"), task_id = task_id, pid = task.pid }) + return + end + + if not fs.access("/etc/init.d/tasks") then + -- fallback (shouldn't happen on iStoreOS with luci-lib-taskd installed) + local cmd = script_path .. " " .. util.shellquote(op) + sys.exec("( " .. cmd .. " ) >/dev/null 2>&1 &") + write_json({ ok = true, queued = true, task_id = task_id, warning = "taskd missing; fallback to background exec" }) + return + end + + local cmd = string.format("\"%s\" %s", script_path, op) + local rc = sys.call("/etc/init.d/tasks task_add " .. task_id .. " " .. util.shellquote(cmd) .. " >/dev/null 2>&1") + if rc == 0 then + write_json({ ok = true, task_id = task_id }) + return + end + + -- busy: try to report which task is running + local running_task = "" + if tasks and tasks.status then + local all = tasks.status("") or {} + for id, st in pairs(all) do + if type(st) == "table" and st.running then + running_task = id + break + end + end + end + + write_json({ ok = false, busy = true, task_id = task_id, running_task_id = running_task }) +end + +local function file_read(path) + local f = io.open(path, "r") + if not f then return nil end + local c = f:read("*a") + f:close() + return c +end + +function action_diag_info() + local sys = require "luci.sys" + local util = require "luci.util" + local uci = require "luci.model.uci".cursor() + + local base_dir = uci:get("openclawmgr", "main", "base_dir") or "/opt/openclawmgr" + local port = uci:get("openclawmgr", "main", "port") or "18789" + local bind = uci:get("openclawmgr", "main", "bind") or "lan" + local enabled = uci:get("openclawmgr", "main", "enabled") or "0" + + if not port:match("^%d+$") then port = "18789" end + + local df_kb = sys.exec("df -kP " .. util.shellquote(base_dir) .. " 2>/dev/null | awk 'NR==2{print $4}'"):gsub("%s+", "") + local avail_mb = math.floor((tonumber(df_kb) or 0) / 1024) + + local node_ver = sys.exec("/usr/libexec/istorec/openclawmgr.sh node_version 2>/dev/null"):gsub("%s+$", "") + local oc_ver = sys.exec("/usr/libexec/istorec/openclawmgr.sh openclaw_version 2>/dev/null"):gsub("%s+$", "") + + local has_node = sys.call("[ -x " .. util.shellquote(base_dir .. "/node/bin/node") .. " ] >/dev/null 2>&1") == 0 + local has_openclaw = sys.call("[ -x " .. util.shellquote(base_dir .. "/global/bin/openclaw") .. " ] >/dev/null 2>&1") == 0 + local has_config = sys.call("[ -f " .. util.shellquote(base_dir .. "/data/.openclaw/openclaw.json") .. " ] >/dev/null 2>&1") == 0 + + local default_gw = sys.exec("ip route 2>/dev/null | awk '/^default/{print $3; exit}' 2>/dev/null"):gsub("%s+", "") + + local svc = util.ubus("service", "list", { name = "openclawmgr" }) + local pid = "" + if type(svc) == "table" and type(svc.openclawmgr) == "table" and type(svc.openclawmgr.instances) == "table" then + local inst = svc.openclawmgr.instances.gateway + if type(inst) == "table" and inst.pid then + pid = tostring(inst.pid) + end + end + local running = pid ~= "" + + write_json({ + ok = true, + base_dir = base_dir, + available_mb = avail_mb, + enabled = enabled, + port = port, + bind = bind, + default_gw = default_gw, + node_version = node_ver, + openclaw_version = oc_ver, + has_node = has_node, + has_openclaw = has_openclaw, + has_config = has_config, + procd_pid = pid, + running = running, + url = "http://" .. get_host() .. ":" .. port .. configured_base_path(base_dir), + }) +end + +function action_diag_run() + local http = require "luci.http" + local sys = require "luci.sys" + local util = require "luci.util" + + if not require_csrf() then + return + end + + local op = http.formvalue("op") or "" + local limit = http.formvalue("limit") or "" + local ok_ops = { + doctor = true, + gateway_status = true, + gateway_health = true, + logs = true, + channels_status = true, + } + if not ok_ops[op] then + write_json({ ok = false, error = "unknown op" }) + return + end + + local cmd = "/usr/libexec/istorec/openclawmgr.sh diag " .. util.shellquote(op) + if op == "logs" and limit:match("^%d+$") then + cmd = cmd .. " " .. util.shellquote(limit) + end + + sys.exec("( " .. cmd .. " ) >/dev/null 2>&1 &") + write_json({ ok = true, queued = true }) +end + +function action_diag_poll() + local http = require "luci.http" + local sys = require "luci.sys" + local util = require "luci.util" + + local op = http.formvalue("op") or "" + local ok_ops = { + doctor = true, + gateway_status = true, + gateway_health = true, + logs = true, + channels_status = true, + } + if not ok_ops[op] then + write_json({ ok = false, error = "unknown op" }) + return + end + + local st = sys.exec("/usr/libexec/istorec/openclawmgr.sh diag_poll " .. util.shellquote(op) .. " 2>/dev/null"):gsub("%s+$", "") + + local log = file_read("/tmp/openclawmgr-diag-" .. op .. ".log") or "" + local exit_code = nil + local state = "idle" + if st == "running" then + state = "running" + elseif st:match("^done:") then + state = "done" + exit_code = tonumber(st:gsub("^done:", "")) or -1 + end + + write_json({ + ok = true, + state = state, + exit_code = exit_code, + log = log, + }) +end diff --git a/luci-app-openclawmgr/luasrc/model/openclawmgr.lua b/luci-app-openclawmgr/luasrc/model/openclawmgr.lua new file mode 100644 index 00000000..dfcc1a84 --- /dev/null +++ b/luci-app-openclawmgr/luasrc/model/openclawmgr.lua @@ -0,0 +1,54 @@ +local jsonc = require "luci.jsonc" + +local openclawmgr = {} + +openclawmgr.blocks = function() + local f = io.popen("lsblk -s -f -b -o NAME,FSSIZE,MOUNTPOINT --json", "r") + local vals = {} + if f then + local ret = f:read("*all") + f:close() + local obj = jsonc.parse(ret) + for _, val in pairs(obj and obj["blockdevices"] or {}) do + local fsize = val["fssize"] + if fsize ~= nil and string.len(fsize) > 10 and val["mountpoint"] then + vals[#vals + 1] = val["mountpoint"] + end + end + end + return vals +end + +openclawmgr.home = function() + local uci = require "luci.model.uci".cursor() + local home_dirs = {} + home_dirs["main_dir"] = uci:get_first("quickstart", "main", "main_dir", "/root") + home_dirs["Configs"] = uci:get_first("quickstart", "main", "conf_dir", home_dirs["main_dir"] .. "/Configs") + home_dirs["Public"] = uci:get_first("quickstart", "main", "pub_dir", home_dirs["main_dir"] .. "/Public") + home_dirs["Downloads"] = uci:get_first("quickstart", "main", "dl_dir", home_dirs["Public"] .. "/Downloads") + home_dirs["Caches"] = uci:get_first("quickstart", "main", "tmp_dir", home_dirs["main_dir"] .. "/Caches") + return home_dirs +end + +openclawmgr.find_paths = function(blocks, home_dirs, path_name) + local default_path = "" + local paths = {} + + default_path = home_dirs[path_name] .. "/OpenClawMgr" + if #blocks == 0 then + table.insert(paths, default_path) + else + for _, val in pairs(blocks) do + table.insert(paths, val .. "/" .. path_name .. "/OpenClawMgr") + end + local without_conf_dir = "/root/" .. path_name .. "/OpenClawMgr" + if default_path == without_conf_dir then + default_path = paths[1] + end + end + + return paths, default_path +end + +return openclawmgr + diff --git a/luci-app-openclawmgr/luasrc/view/openclawmgr/app.htm b/luci-app-openclawmgr/luasrc/view/openclawmgr/app.htm new file mode 100644 index 00000000..2706524d --- /dev/null +++ b/luci-app-openclawmgr/luasrc/view/openclawmgr/app.htm @@ -0,0 +1,19 @@ +<%+header%> + +
+
+
+ +<%+footer%> diff --git a/luci-app-openclawmgr/luasrc/view/openclawmgr/diag.htm b/luci-app-openclawmgr/luasrc/view/openclawmgr/diag.htm new file mode 100644 index 00000000..4ce37ecd --- /dev/null +++ b/luci-app-openclawmgr/luasrc/view/openclawmgr/diag.htm @@ -0,0 +1,258 @@ +<%+header%> + +<% +local info_url = luci.dispatcher.build_url("admin", "services", "openclawmgr", "diag_info") +local run_url = luci.dispatcher.build_url("admin", "services", "openclawmgr", "diag_run") +local poll_url = luci.dispatcher.build_url("admin", "services", "openclawmgr", "diag_poll") +local op_url = luci.dispatcher.build_url("admin", "services", "openclawmgr", "op") +local log_url = luci.dispatcher.build_url("admin", "services", "openclawmgr", "log") +local token = (luci.dispatcher.context and luci.dispatcher.context.token) or "" +%> + +<%+tasks/embed%> + + + +
+
<%:Quick Checks%>
+
+
+
+
<%:Base Dir%>: -
+
<%:Disk Available%>: -
+
<%:Enable%>: -
+
<%:Running%>: -
+
+
+
<%:Node%>: - -
+
<%:OpenClaw%>: - -
+
<%:Config%>: -
+
<%:URL%>: -
+
+
+
+
+
+ +
+
<%:Service Actions%>
+
+
+ + + + + + +
+
+ <%:Purge is destructive and will remove data under Base Dir. Use it only for troubleshooting.%> +
+
+
+ +
+
<%:Diagnostics%>
+
+
+ + + + + + +
+
+ <%:All actions run in background; output will appear below.%> +
+
+
-
+
+
+
+ + + +<%+footer%> diff --git a/luci-app-openclawmgr/po/zh-cn/openclawmgr.po b/luci-app-openclawmgr/po/zh-cn/openclawmgr.po new file mode 100644 index 00000000..ec2c5c41 --- /dev/null +++ b/luci-app-openclawmgr/po/zh-cn/openclawmgr.po @@ -0,0 +1,245 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8\n" + +msgid "OpenClaw" +msgstr "OpenClaw" + +msgid "OpenClaw Launcher" +msgstr "OpenClaw 启动器" + +msgid "Native installer & service manager for OpenClaw (procd + Node/npm)." +msgstr "OpenClaw 原生安装器与服务管理(procd + Node/npm)。" + +msgid "Status" +msgstr "状态" + +msgid "Open Web UI" +msgstr "打开 Web 界面" + +msgid "Unavailable" +msgstr "不可用" + +msgid "Service Status" +msgstr "服务状态" + +msgid "Installed" +msgstr "已安装" + +msgid "Running" +msgstr "运行中" + +msgid "Not running" +msgstr "未运行" + +msgid "Not installed" +msgstr "未安装" + +msgid "Node" +msgstr "Node.js" + +msgid "URL" +msgstr "访问地址" + +msgid "Actions" +msgstr "操作" + +msgid "Install" +msgstr "安装" + +msgid "Apply Settings" +msgstr "应用设置" + +msgid "Upgrade" +msgstr "升级" + +msgid "Start" +msgstr "启动" + +msgid "Stop" +msgstr "停止" + +msgid "Restart" +msgstr "重启" + +msgid "Purge" +msgstr "清理(含数据)" + +msgid "Installer Log" +msgstr "安装日志" + +msgid "Service Actions" +msgstr "服务操作" + +msgid "Restart Service" +msgstr "重启服务" + +msgid "Purge will remove runtime and data under Base Dir. Continue?" +msgstr "清理将删除运行时与安装目录下的数据。确定继续?" + +msgid "Purge is destructive and will remove data under Base Dir. Use it only for troubleshooting." +msgstr "清理操作具有破坏性,会删除安装目录下的数据;仅建议用于排障。" + +msgid "Install/upgrade runs in background; check logs in the popup task window." +msgstr "安装/升级在后台执行;请在弹出的任务日志窗口查看。" + +msgid "Task log will pop up after clicking an action." +msgstr "点击操作后会弹出任务日志窗口。" + +msgid "Task window is not available (taskd/xterm not loaded). Please install luci-lib-taskd and luci-lib-xterm, then hard refresh the page." +msgstr "任务窗口不可用(未加载 taskd/xterm)。请安装 luci-lib-taskd 和 luci-lib-xterm,然后强制刷新页面。" + +msgid "Config saved, but apply failed" +msgstr "配置已保存,但应用失败" + +msgid "Another task running, try again later." +msgstr "有任务正在运行,请稍后再试。" + +msgid "Click here to check running task" +msgstr "点击这里查看正在运行的任务" + +msgid "Task Running" +msgstr "任务运行中" + +msgid "Installing" +msgstr "安装中" + +msgid "Stop Install" +msgstr "停止安装" + +msgid "Failed to stop installation" +msgstr "停止安装失败" + +msgid "No install task is running" +msgstr "当前没有正在执行的安装任务" + +msgid "taskd is not available" +msgstr "taskd 不可用" + +msgid "Diagnostics" +msgstr "诊断" + +msgid "Quick Checks" +msgstr "快速检查" + +msgid "Disk Available" +msgstr "可用空间" + +msgid "Config" +msgstr "配置文件" + +msgid "Run Doctor" +msgstr "运行 Doctor" + +msgid "Gateway Status" +msgstr "网关状态" + +msgid "Gateway Health" +msgstr "网关健康检查" + +msgid "Gateway Logs" +msgstr "网关日志" + +msgid "Channels Status" +msgstr "渠道状态" + +msgid "All actions run in background; output will appear below." +msgstr "所有操作在后台执行;输出会显示在下方。" + +msgid "Settings" +msgstr "设置" + +msgid "Enable" +msgstr "启用" + +msgid "Port" +msgstr "端口" + +msgid "Bind" +msgstr "监听地址" + +msgid "Loopback" +msgstr "仅本机" + +msgid "LAN" +msgstr "LAN" + +msgid "Auto" +msgstr "自动" + +msgid "Base Dir" +msgstr "安装目录" + +msgid "Recommended. OpenClaw Launcher will install Node.js/OpenClaw runtime and store data under this directory." +msgstr "推荐设置。OpenClaw 启动器会在该目录下安装 Node.js/OpenClaw 运行时并存放数据。" + +msgid "Gateway Token" +msgstr "网关 Token" + +msgid "Allowed Origins" +msgstr "允许的访问地址" + +msgid "Origins allowed to access Control UI over HTTP. Example: http://lan-ip:port" +msgstr "允许通过 HTTP 访问控制台的来源域名,例如 http://LAN-IP:端口" + +msgid "Allow Insecure HTTP Control UI" +msgstr "允许 HTTP 访问" + +msgid "Enable token-only Control UI access over HTTP (no device identity). Recommended only on trusted LAN; prefer HTTPS when possible." +msgstr "允许通过 HTTP 使用仅令牌访问控制台(不校验设备身份)。仅建议在受信任的局域网使用,尽量使用 HTTPS。" + +msgid "Disable Device Auth" +msgstr "关闭设备校验" + +msgid "Disable Control UI device identity checks (dangerous). Use only on trusted LAN." +msgstr "关闭控制台设备身份校验(危险)。仅建议在受信任的局域网使用。" + +msgid "Default Agent" +msgstr "默认 AGENT" + +msgid "Select the provider used by agents.defaults.model.primary." +msgstr "选择 agents.defaults.model.primary 使用的提供商。" + +msgid "API Key" +msgstr "API Key" + +msgid "Only updates the API key for the selected Default Agent." +msgstr "只更新当前所选默认 AGENT 对应的 API Key。" + +msgid "Relay URL" +msgstr "中转地址" + +msgid "Optional. Must start with http:// or https://. Leave empty to restore the selected provider's default baseUrl." +msgstr "可选。必须以 http:// 或 https:// 开头。留空会恢复当前所选 provider 的默认 baseUrl。" + +msgid "Relay URL must start with http:// or https://" +msgstr "中转地址必须以 http:// 或 https:// 开头" + +msgid "Default Model" +msgstr "默认模型" + +msgid "OpenAI" +msgstr "OpenAI" + +msgid "Anthropic" +msgstr "Anthropic" + +msgid "MiniMax CN" +msgstr "MiniMax CN" + +msgid "Moonshot CN" +msgstr "Moonshot CN" + +msgid "Uninstall" +msgstr "卸载(保留数据)" + +msgid "Uninstall OpenClaw (Keep Node)" +msgstr "卸载 OpenClaw(保留 Node)" + +msgid "Uninstall OpenClaw runtime but keep Node.js. Continue?" +msgstr "卸载 OpenClaw 运行时但保留 Node.js。确定继续?" + +msgid "Uninstall will remove runtime but keep data under Base Dir. Continue?" +msgstr "卸载将删除运行时,但保留安装目录下的数据。确定继续?" + +msgid "Network Diagnostics" +msgstr "网络诊断" diff --git a/luci-app-openclawmgr/po/zh_Hans b/luci-app-openclawmgr/po/zh_Hans new file mode 120000 index 00000000..41451e4a --- /dev/null +++ b/luci-app-openclawmgr/po/zh_Hans @@ -0,0 +1 @@ +zh-cn \ No newline at end of file diff --git a/luci-app-openclawmgr/root/etc/config/openclawmgr b/luci-app-openclawmgr/root/etc/config/openclawmgr new file mode 100644 index 00000000..cd8775df --- /dev/null +++ b/luci-app-openclawmgr/root/etc/config/openclawmgr @@ -0,0 +1,14 @@ +config openclawmgr 'main' + option enabled '0' + option port '18789' + option bind 'lan' + option base_dir '/opt/openclawmgr' + option token '' + option allow_insecure_auth '0' + option disable_device_auth '0' + option node_version '22.16.0' + option install_accelerated '1' + option default_agent 'anthropic' + option default_model '' + option provider_api_key '' + option provider_base_url '' diff --git a/luci-app-openclawmgr/root/etc/init.d/openclawmgr b/luci-app-openclawmgr/root/etc/init.d/openclawmgr new file mode 100755 index 00000000..45a28a60 --- /dev/null +++ b/luci-app-openclawmgr/root/etc/init.d/openclawmgr @@ -0,0 +1,87 @@ +#!/bin/sh /etc/rc.common + +USE_PROCD=1 +START=99 +STOP=10 + +SERVICE_NAME="openclawmgr" + +_find_entry() { + local global_dir="$1" + local d="${global_dir}/lib/node_modules/openclaw" + [ -f "${d}/openclaw.mjs" ] && { echo "${d}/openclaw.mjs"; return 0; } + [ -f "${d}/dist/cli.js" ] && { echo "${d}/dist/cli.js"; return 0; } + return 1 +} + +start_service() { + config_load openclawmgr + config_get_bool enabled main enabled 0 + config_get port main port 18789 + config_get bind main bind lan + config_get base_dir main base_dir /opt/openclawmgr + config_get token main token "" + + [ "$enabled" -eq 1 ] || return 0 + + local node_bin="${base_dir}/node/bin/node" + local global_dir="${base_dir}/global" + local data_dir="${base_dir}/data" + local env_file="${data_dir}/.openclaw/openclaw.env" + local entry="$(_find_entry "$global_dir")" + local openai_api_key="" + local anthropic_api_key="" + local minimax_api_key="" + local moonshot_api_key="" + + [ -x "$node_bin" ] || return 1 + [ -n "$entry" ] || return 1 + [ -n "$token" ] || return 1 + + mkdir -p "${data_dir}/.openclaw" 2>/dev/null + if [ -f "$env_file" ]; then + # shellcheck disable=SC1090 + . "$env_file" + openai_api_key="${OPENAI_API_KEY:-}" + anthropic_api_key="${ANTHROPIC_API_KEY:-}" + minimax_api_key="${MINIMAX_API_KEY:-}" + moonshot_api_key="${MOONSHOT_API_KEY:-}" + fi + + # OpenClaw uses --force to kill conflicting processes, but requires either `lsof` + # or `fuser`. iStoreOS images may not include them by default. + local force_opt="" + if command -v lsof >/dev/null 2>&1 || command -v fuser >/dev/null 2>&1; then + force_opt="--force" + fi + + local user_opt="" + if id openclawmgr >/dev/null 2>&1; then + user_opt="openclawmgr" + fi + + procd_open_instance "gateway" + procd_set_param command "$node_bin" "$entry" gateway run --port "$port" --bind "$bind" --auth token --token "$token" --allow-unconfigured $force_opt + procd_set_param env \ + HOME="$data_dir" \ + OPENCLAW_HOME="$data_dir" \ + OPENCLAW_STATE_DIR="${data_dir}/.openclaw" \ + OPENCLAW_CONFIG_PATH="${data_dir}/.openclaw/openclaw.json" \ + OPENCLAW_GATEWAY_PORT="$port" \ + OPENCLAW_GATEWAY_BIND="$bind" \ + OPENCLAW_GATEWAY_TOKEN="$token" \ + OPENAI_API_KEY="$openai_api_key" \ + ANTHROPIC_API_KEY="$anthropic_api_key" \ + MINIMAX_API_KEY="$minimax_api_key" \ + MOONSHOT_API_KEY="$moonshot_api_key" \ + PATH="${base_dir}/node/bin:${global_dir}/bin:/usr/sbin:/usr/bin:/sbin:/bin" + [ -n "$user_opt" ] && procd_set_param user "$user_opt" + procd_set_param respawn 3600 10 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +service_triggers() { + procd_add_reload_trigger "openclawmgr" +} diff --git a/luci-app-openclawmgr/root/etc/uci-defaults/99-openclawmgr b/luci-app-openclawmgr/root/etc/uci-defaults/99-openclawmgr new file mode 100755 index 00000000..74442c1f --- /dev/null +++ b/luci-app-openclawmgr/root/etc/uci-defaults/99-openclawmgr @@ -0,0 +1,46 @@ +#!/bin/sh + +conf_dir="$(uci -q get quickstart.@main[0].conf_dir 2>/dev/null || true)" +[ -n "$conf_dir" ] && suggested_base_dir="$conf_dir/OpenClawMgr" || suggested_base_dir="" + +# Ensure section exists +if ! uci -q get openclawmgr.main >/dev/null 2>&1; then + uci -q set openclawmgr.main=openclawmgr >/dev/null 2>&1 || true +fi + +# Fill defaults (only if missing) +[ -n "$(uci -q get openclawmgr.main.enabled 2>/dev/null)" ] || uci -q set openclawmgr.main.enabled='0' +[ -n "$(uci -q get openclawmgr.main.port 2>/dev/null)" ] || uci -q set openclawmgr.main.port='18789' +[ -n "$(uci -q get openclawmgr.main.bind 2>/dev/null)" ] || uci -q set openclawmgr.main.bind='lan' +[ -n "$(uci -q get openclawmgr.main.node_version 2>/dev/null)" ] || uci -q set openclawmgr.main.node_version='22.16.0' +[ -n "$(uci -q get openclawmgr.main.install_accelerated 2>/dev/null)" ] || uci -q set openclawmgr.main.install_accelerated='1' +[ -n "$(uci -q get openclawmgr.main.token 2>/dev/null)" ] || uci -q set openclawmgr.main.token='' +[ -n "$(uci -q get openclawmgr.main.allow_insecure_auth 2>/dev/null)" ] || uci -q set openclawmgr.main.allow_insecure_auth='0' +[ -n "$(uci -q get openclawmgr.main.disable_device_auth 2>/dev/null)" ] || uci -q set openclawmgr.main.disable_device_auth='0' +[ -n "$(uci -q get openclawmgr.main.default_agent 2>/dev/null)" ] || uci -q set openclawmgr.main.default_agent='anthropic' +[ -n "$(uci -q get openclawmgr.main.default_model 2>/dev/null)" ] || uci -q set openclawmgr.main.default_model='' +[ -n "$(uci -q get openclawmgr.main.provider_api_key 2>/dev/null)" ] || uci -q set openclawmgr.main.provider_api_key='' +[ -n "$(uci -q get openclawmgr.main.provider_base_url 2>/dev/null)" ] || uci -q set openclawmgr.main.provider_base_url='' + +# Improve default base_dir on iStoreOS: if still unset or using legacy /opt, prefer quickstart conf_dir. +current_base_dir="$(uci -q get openclawmgr.main.base_dir 2>/dev/null || true)" +if [ -z "$current_base_dir" ] || [ "$current_base_dir" = "/opt/openclawmgr" ]; then + if [ -n "$suggested_base_dir" ]; then + uci -q set openclawmgr.main.base_dir="$suggested_base_dir" + else + uci -q set openclawmgr.main.base_dir='/opt/openclawmgr' + fi +fi + +uci -q commit openclawmgr >/dev/null 2>&1 || true + +# best-effort: create an unprivileged user for procd +if ! id openclawmgr >/dev/null 2>&1; then + if command -v adduser >/dev/null 2>&1; then + adduser -D -H -s /bin/false openclawmgr >/dev/null 2>&1 || true + elif command -v useradd >/dev/null 2>&1; then + useradd -r -M -s /bin/false openclawmgr >/dev/null 2>&1 || true + fi +fi + +exit 0 diff --git a/luci-app-openclawmgr/root/usr/libexec/istorec/openclawmgr.sh b/luci-app-openclawmgr/root/usr/libexec/istorec/openclawmgr.sh new file mode 100755 index 00000000..35318b5c --- /dev/null +++ b/luci-app-openclawmgr/root/usr/libexec/istorec/openclawmgr.sh @@ -0,0 +1,1161 @@ +#!/bin/sh + +set -eu + +APP="openclawmgr" +UCI_NS="openclawmgr" +LOCK_DIR="/tmp/openclawmgr-installer.lock" +DIAG_PREFIX="/tmp/openclawmgr-diag" + +log_ts() { date "+%Y-%m-%d %H:%M:%S"; } + +uci_get() { uci -q get "${UCI_NS}.main.$1" 2>/dev/null || true; } + +uci_get_list() { + local key="$1" + uci -q show "${UCI_NS}.main.${key}" 2>/dev/null | \ + sed -n "s/^${UCI_NS}\\.main\\.${key}='\\(.*\\)'$/\\1/p" +} + +fmt_elapsed() { + local total="${1:-0}" h m s + case "$total" in ''|*[!0-9]*) total=0 ;; esac + h=$((total / 3600)) + m=$(((total % 3600) / 60)) + s=$((total % 60)) + if [ "$h" -gt 0 ]; then + printf "%dh%dm%ds" "$h" "$m" "$s" + elif [ "$m" -gt 0 ]; then + printf "%dm%ds" "$m" "$s" + else + printf "%ds" "$s" + fi +} + +download_with_progress() { + local url="$1" out="$2" err="$3" label="$4" + local start_ts last_change_ts last_size last_report_ts + + write_installer_log "$label: $url" + + curl -fSL --retry 2 --retry-delay 1 --connect-timeout 15 --max-time 600 \ + -o "$out" "$url" 2>"$err" & + local cpid="$!" + + start_ts="$(date +%s 2>/dev/null || echo 0)" + last_change_ts="$start_ts" + last_size=0 + last_report_ts="$start_ts" + + while kill -0 "$cpid" 2>/dev/null; do + sleep 30 + kill -0 "$cpid" 2>/dev/null || break + + local now_ts size quiet_s elapsed_s elapsed_h size_mb + now_ts="$(date +%s 2>/dev/null || echo 0)" + size="$(wc -c <"$out" 2>/dev/null || echo 0)" + case "$size" in ''|*[!0-9]*) size=0 ;; esac + if [ "$size" -ne "$last_size" ]; then + last_size="$size" + last_change_ts="$now_ts" + fi + + if [ $((now_ts - last_report_ts)) -ge 60 ]; then + last_report_ts="$now_ts" + quiet_s=$((now_ts - last_change_ts)) + elapsed_s=$((now_ts - start_ts)) + elapsed_h="$(fmt_elapsed "$elapsed_s")" + size_mb=$((size / 1048576)) + + if [ "$size" -gt 0 ]; then + if [ "$quiet_s" -ge 60 ]; then + write_installer_log "$label running (${elapsed_h} elapsed; no new data for ${quiet_s}s; downloaded ${size_mb}MB)" + else + write_installer_log "$label running (${elapsed_h} elapsed; downloaded ${size_mb}MB)" + fi + else + write_installer_log "$label running (${elapsed_h} elapsed; no data yet)" + fi + fi + done + + wait "$cpid" + return $? +} + +default_allowed_origins() { + local port="$PORT" ip + ip="$(uci -q get network.lan.ipaddr 2>/dev/null || true)" + if [ -z "$ip" ]; then + ip="$(ip -4 -o addr show br-lan 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -n 1)" + fi + if [ -n "$ip" ]; then + printf "http://%s:%s\n" "$ip" "$port" + fi +} + +build_allowed_origins_json() { + local origins="${1:-}" + printf "%s" "$origins" | awk ' + BEGIN { first=1; print "[" } + { + gsub(/^[ \t]+|[ \t]+$/, "", $0) + if ($0 == "") next + gsub(/\\/,"\\\\",$0); gsub(/"/,"\\\"",$0) + if (first) { first=0; printf " \"%s\"", $0 } + else { printf ",\n \"%s\"", $0 } + } + END { + if (first) { print "]" } else { print "\n ]" } + }' +} + +has_default_route() { + ip route 2>/dev/null | grep -q '^default ' +} + +default_gateway() { + ip route 2>/dev/null | awk '/^default/{print $3; exit}' 2>/dev/null || true +} + +ensure_dirs() { + mkdir -p "$BASE_DIR" "$BASE_DIR/log" "$NODE_DIR" "$GLOBAL_DIR" "$DATA_DIR/.openclaw" 2>/dev/null || true +} + +check_space_mb() { + local path="$1" need_mb="$2" + local avail_kb + avail_kb="$(df -kP "$path" 2>/dev/null | awk 'NR==2{print $4}' 2>/dev/null || echo 0)" + [ -n "$avail_kb" ] || avail_kb=0 + if [ "$avail_kb" -lt $((need_mb * 1024)) ] 2>/dev/null; then + write_installer_log "Insufficient disk space at $path: need ${need_mb}MB, available $((avail_kb / 1024))MB" + return 1 + fi + return 0 +} + +proc_ppid() { + local pid="$1" + [ -r "/proc/$pid/stat" ] || return 1 + awk '{print $4}' "/proc/$pid/stat" 2>/dev/null +} + +pid_in_own_ancestry() { + local target="$1" cur depth + case "$target" in ''|*[!0-9]*) return 1 ;; esac + + cur="$$" + depth=0 + while [ -n "$cur" ] && [ "$cur" -gt 1 ] 2>/dev/null && [ "$depth" -lt 64 ]; do + [ "$cur" = "$target" ] && return 0 + cur="$(proc_ppid "$cur" 2>/dev/null || true)" + depth=$((depth + 1)) + done + + return 1 +} + +acquire_lock() { + if mkdir "$LOCK_DIR" 2>/dev/null; then + echo "$$" >"$LOCK_DIR/pid" 2>/dev/null || true + trap 'rm -rf "$LOCK_DIR" 2>/dev/null || true' EXIT + return 0 + fi + + # Lock exists: check if it's stale (e.g. previous task killed or power loss). + if [ -d "$LOCK_DIR" ]; then + local pid running + pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + write_installer_log "Another operation is running (lock: $LOCK_DIR, pid: $pid)" + exit 2 + fi + + running="false" + if [ -x /etc/init.d/tasks ] && command -v jsonfilter >/dev/null 2>&1; then + local st tpid + st="$(/etc/init.d/tasks task_status "$APP" 2>/dev/null || true)" + running="$(printf "%s" "$st" | jsonfilter -e '@.running' 2>/dev/null || echo false)" + tpid="$(printf "%s" "$st" | jsonfilter -e '@.pid' 2>/dev/null || true)" + if [ "$running" = "true" ]; then + if [ -n "${tpid:-}" ] && kill -0 "$tpid" 2>/dev/null; then + if pid_in_own_ancestry "$tpid"; then + write_installer_log "Stale lock detected for current task, recovering: $LOCK_DIR" + running="false" + else + write_installer_log "Another operation is running (taskd: $APP, pid: $tpid)" + exit 2 + fi + fi + if [ "$running" = "true" ]; then + # task_status says running, but pid is gone -> stale task record + write_installer_log "Stale task record detected, clearing: $APP" + /etc/init.d/tasks task_del "$APP" >/dev/null 2>&1 || true + running="false" + fi + fi + fi + if [ "$running" = "true" ]; then + write_installer_log "Another operation is running (taskd: $APP)" + exit 2 + fi + + write_installer_log "Stale lock detected, removing: $LOCK_DIR" + rm -rf "$LOCK_DIR" 2>/dev/null || true + if mkdir "$LOCK_DIR" 2>/dev/null; then + echo "$$" >"$LOCK_DIR/pid" 2>/dev/null || true + trap 'rm -rf "$LOCK_DIR" 2>/dev/null || true' EXIT + return 0 + fi + fi + + write_installer_log "Another operation is running (lock: $LOCK_DIR)" + exit 2 +} + +gen_token() { + (hexdump -n 24 -e '24/1 "%02x"' /dev/urandom 2>/dev/null) || \ + (openssl rand -hex 24 2>/dev/null) || \ + (date +%s | md5sum 2>/dev/null | awk '{print $1}') +} + +is_musl() { + ldd --version 2>&1 | grep -qi musl && return 0 + [ -e /lib/ld-musl-*.so.1 ] 2>/dev/null && return 0 + return 1 +} + +detect_arch() { + case "$(uname -m 2>/dev/null || echo unknown)" in + x86_64) echo "linux-x64" ;; + aarch64) echo "linux-arm64" ;; + *) echo "unsupported" ;; + esac +} + +ks_admin_port() { + local en ap + en="$(uci -q get istoreenhance.@istoreenhance[0].enabled 2>/dev/null || echo 0)" + [ "$en" = "1" ] || return 1 + pidof iStoreEnhance >/dev/null 2>&1 || return 1 + ap="$(uci -q get istoreenhance.@istoreenhance[0].adminport 2>/dev/null || echo 5003)" + echo "$ap" +} + +download() { + local url="$1" out="$2" + local ap admin_path resp err tmp rc ks_enabled + + tmp="/tmp/${APP}-curl.err" + err="/tmp/${APP}-curl.err2" + rm -f "$tmp" "$err" 2>/dev/null || true + + if [ "${INSTALL_ACCELERATED:-1}" != "1" ]; then + write_installer_log "KSpeeder not used: accelerated install is disabled" + else + ks_enabled="$(uci -q get istoreenhance.@istoreenhance[0].enabled 2>/dev/null || echo 0)" + if [ "$ks_enabled" != "1" ]; then + write_installer_log "KSpeeder not used: istoreenhance is disabled" + elif ! pidof iStoreEnhance >/dev/null 2>&1; then + write_installer_log "KSpeeder not used: iStoreEnhance process is not running" + else + ap="$(uci -q get istoreenhance.@istoreenhance[0].adminport 2>/dev/null || echo 5003)" + resp="$(curl -fsS --connect-timeout 2 --max-time 5 -G \ + --data-urlencode "url=${url}" \ + "http://127.0.0.1:${ap}/api/domainfold/remap" 2>"$tmp" || true)" + admin_path="$(printf "%s" "$resp" | jsonfilter -e '@.admin_path' 2>/dev/null || true)" + if [ -n "$admin_path" ] && echo "$admin_path" | grep -q '^/'; then + if download_with_progress "http://127.0.0.1:${ap}${admin_path}" "$out" "$err" "Downloading via KSpeeder"; then + return 0 + fi + rc=$? + write_installer_log "Download via KSpeeder failed (rc=$rc): ${url}" + tail -n 5 "$err" 2>/dev/null | while IFS= read -r ln; do write_installer_log "$ln"; done + else + local e + e="$(printf "%s" "$resp" | jsonfilter -e '@.error' 2>/dev/null || true)" + if [ -n "$e" ]; then + write_installer_log "KSpeeder remap failed: $e" + elif [ -s "$tmp" ]; then + write_installer_log "KSpeeder not used: remap request failed on 127.0.0.1:${ap}" + tail -n 3 "$tmp" 2>/dev/null | while IFS= read -r ln; do write_installer_log "$ln"; done + else + write_installer_log "KSpeeder not used: remap response missing admin_path" + fi + fi + fi + fi + + if ! has_default_route; then + write_installer_log "No default route (default gateway missing); internet is unreachable" + fi + + if download_with_progress "$url" "$out" "$err" "Downloading"; then + return 0 + fi + rc=$? + write_installer_log "Download failed (rc=$rc): ${url}" + tail -n 5 "$err" 2>/dev/null | while IFS= read -r ln; do write_installer_log "$ln"; done + return "$rc" +} + +write_installer_log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + local line + line="$(printf "%s [%s] %s" "$(log_ts)" "$APP" "$msg")" + printf "%s\n" "$line" + printf "%s\n" "$line" >> "$LOG_FILE" +} + +openclaw_bin() { + local b="${GLOBAL_DIR}/bin/openclaw" + [ -x "$b" ] && { echo "$b"; return 0; } + return 1 +} + +openclaw_cmd() { + local b + b="$(openclaw_bin 2>/dev/null || true)" + [ -n "$b" ] || return 127 + HOME="$DATA_DIR" \ + OPENCLAW_HOME="$DATA_DIR" \ + OPENCLAW_STATE_DIR="${DATA_DIR}/.openclaw" \ + OPENCLAW_CONFIG_PATH="${DATA_DIR}/.openclaw/openclaw.json" \ + PATH="${NODE_DIR}/bin:${GLOBAL_DIR}/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + "$b" "$@" +} + +find_entry() { + local d="${GLOBAL_DIR}/lib/node_modules/openclaw" + [ -f "${d}/openclaw.mjs" ] && { echo "${d}/openclaw.mjs"; return 0; } + [ -f "${d}/dist/cli.js" ] && { echo "${d}/dist/cli.js"; return 0; } + return 1 +} + +node_version() { + [ -x "$NODE_BIN" ] || return 0 + "$NODE_BIN" --version 2>/dev/null || true +} + +openclaw_version() { + local pkg="${GLOBAL_DIR}/lib/node_modules/openclaw/package.json" + [ -f "$pkg" ] || return 0 + sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$pkg" 2>/dev/null | head -n 1 +} + +have_openclaw_runtime() { + [ -x "$NODE_BIN" ] || return 1 + find_entry >/dev/null 2>&1 || return 1 + return 0 +} + +agent_key_name() { + case "${DEFAULT_AGENT:-anthropic}" in + openai) echo "OPENAI_API_KEY" ;; + anthropic) echo "ANTHROPIC_API_KEY" ;; + minimax-cn) echo "MINIMAX_API_KEY" ;; + moonshot) echo "MOONSHOT_API_KEY" ;; + *) echo "ANTHROPIC_API_KEY" ;; + esac +} + +agent_default_model() { + if [ -n "${DEFAULT_MODEL:-}" ]; then + echo "$DEFAULT_MODEL" + return 0 + fi + case "${DEFAULT_AGENT:-anthropic}" in + openai) echo "openai/gpt-5.2" ;; + anthropic) echo "anthropic/claude-sonnet-4-6" ;; + minimax-cn) echo "minimax-cn/MiniMax-M2.5" ;; + moonshot) echo "moonshot/kimi-k2.5" ;; + *) echo "anthropic/claude-sonnet-4-6" ;; + esac +} + +agent_default_base_url() { + case "${DEFAULT_AGENT:-anthropic}" in + openai) echo "https://api.openai.com/v1" ;; + anthropic) echo "https://api.anthropic.com" ;; + minimax-cn) echo "https://api.minimaxi.com/anthropic" ;; + moonshot) echo "https://api.moonshot.cn/v1" ;; + *) echo "https://api.anthropic.com" ;; + esac +} + +is_valid_http_url() { + case "${1:-}" in + http://*|https://*) return 0 ;; + *) return 1 ;; + esac +} + +ensure_gateway_config() { + local cfg="${DATA_DIR}/.openclaw/openclaw.json" + [ -n "${TOKEN:-}" ] || return 1 + + if [ -f "$cfg" ]; then + # Only overwrite configs that look managed by OpenClawMgr (backward compatible). + if ! grep -Eq '"controlUi"[[:space:]]*:' "$cfg" 2>/dev/null && \ + ! grep -Eq '"basePath"[[:space:]]*:[[:space:]]*"\\?/openclawmgr"' "$cfg" 2>/dev/null; then + write_installer_log "Skipping config update because ${cfg} does not look managed by OpenClawMgr" + return 0 + fi + fi + + local host_header_fallback="false" + if [ "$BIND" != "loopback" ]; then + host_header_fallback="true" + fi + + local allow_insecure="false" + if [ "${ALLOW_INSECURE_AUTH:-0}" = "1" ]; then + allow_insecure="true" + fi + + local disable_device_auth="false" + if [ "${DISABLE_DEVICE_AUTH:-0}" = "1" ]; then + disable_device_auth="true" + fi + + local allowed_origins="$ALLOWED_ORIGINS" + if [ -z "$allowed_origins" ]; then + allowed_origins="$(default_allowed_origins)" + fi + local selected_key selected_model selected_base_url override_base_url base_url_mode + selected_key="$(agent_key_name)" + selected_model="$(agent_default_model)" + selected_base_url="$(agent_default_base_url)" + override_base_url="" + base_url_mode="default" + if [ -n "${PROVIDER_BASE_URL:-}" ]; then + if is_valid_http_url "$PROVIDER_BASE_URL"; then + override_base_url="$PROVIDER_BASE_URL" + base_url_mode="override" + else + write_installer_log "Ignoring invalid relay URL for ${DEFAULT_AGENT:-anthropic}: ${PROVIDER_BASE_URL}" + base_url_mode="preserve" + fi + fi + + mkdir -p "$(dirname "$cfg")" 2>/dev/null || true + CFG_PATH="$cfg" \ + ALLOWED_ORIGINS_RAW="$allowed_origins" \ + GATEWAY_PORT="$PORT" \ + GATEWAY_BIND="$BIND" \ + GATEWAY_TOKEN="$TOKEN" \ + GATEWAY_ALLOW_INSECURE="$allow_insecure" \ + GATEWAY_DISABLE_DEVICE_AUTH="$disable_device_auth" \ + GATEWAY_HOST_HEADER_FALLBACK="$host_header_fallback" \ + DEFAULT_AGENT_NAME="${DEFAULT_AGENT:-anthropic}" \ + DEFAULT_AGENT_KEY="$selected_key" \ + DEFAULT_AGENT_MODEL="$selected_model" \ + DEFAULT_AGENT_BASE_URL="$selected_base_url" \ + DEFAULT_AGENT_OVERRIDE_BASE_URL="$override_base_url" \ + DEFAULT_AGENT_BASE_URL_MODE="$base_url_mode" \ + DEFAULT_AGENT_API_KEY="${PROVIDER_API_KEY:-}" \ + lua - <<'EOF' +local json = require "luci.jsonc" + +local cfg_path = os.getenv("CFG_PATH") +local function slurp(path) + local f = io.open(path, "r") + if not f then return nil end + local data = f:read("*a") + f:close() + return data +end + +local function bool_env(name) + return os.getenv(name) == "true" +end + +local function split_lines(raw) + local out = {} + raw = raw or "" + for line in raw:gmatch("([^\n]+)") do + line = line:gsub("^%s+", ""):gsub("%s+$", "") + if line ~= "" then + table.insert(out, line) + end + end + return out +end + +local function ensure_table(parent, key) + if type(parent[key]) ~= "table" then + parent[key] = {} + end + return parent[key] +end + +local function read_config(path) + local raw = slurp(path) + if not raw or raw == "" then + return {} + end + return json.parse(raw) or {} +end + +local cfg = read_config(cfg_path) + +local gateway = ensure_table(cfg, "gateway") +gateway.mode = "local" +gateway.port = tonumber(os.getenv("GATEWAY_PORT")) or 18789 +gateway.bind = os.getenv("GATEWAY_BIND") or "lan" +gateway.auth = { mode = "token", token = os.getenv("GATEWAY_TOKEN") or "" } + +local control = ensure_table(gateway, "controlUi") +control.enabled = true +control.basePath = nil +control.allowedOrigins = split_lines(os.getenv("ALLOWED_ORIGINS_RAW")) +control.allowInsecureAuth = bool_env("GATEWAY_ALLOW_INSECURE") +control.dangerouslyDisableDeviceAuth = bool_env("GATEWAY_DISABLE_DEVICE_AUTH") +control.dangerouslyAllowHostHeaderOriginFallback = bool_env("GATEWAY_HOST_HEADER_FALLBACK") + +local env = {} +local selected_env_key = os.getenv("DEFAULT_AGENT_KEY") +local selected_api_key = os.getenv("DEFAULT_AGENT_API_KEY") or "" +if selected_env_key and selected_api_key ~= "" then + env[selected_env_key] = selected_api_key +end +cfg.env = next(env) and env or nil + +local models = {} +models.mode = "merge" +local providers = {} +models.providers = providers +cfg.models = models + +local current_provider_id = os.getenv("DEFAULT_AGENT_NAME") or "anthropic" +local current_provider = {} +providers[current_provider_id] = current_provider + +if current_provider_id == "openai" then + current_provider.api = "openai-completions" + current_provider.baseUrl = "https://api.openai.com/v1" + current_provider.apiKey = selected_api_key + current_provider.models = { + { id = "gpt-5.2", name = "GPT-5.2" }, + } +elseif current_provider_id == "anthropic" then + current_provider.api = "anthropic-messages" + current_provider.baseUrl = "https://api.anthropic.com" + current_provider.apiKey = selected_api_key + current_provider.models = { + { id = "claude-sonnet-4-6", name = "Claude Sonnet 4.6" }, + } +elseif current_provider_id == "minimax-cn" then + current_provider.api = "anthropic-messages" + current_provider.baseUrl = "https://api.minimaxi.com/anthropic" + current_provider.apiKey = selected_api_key + current_provider.authHeader = true + current_provider.models = { + { id = "MiniMax-M2.5", name = "MiniMax M2.5" }, + } +elseif current_provider_id == "moonshot" then + current_provider.api = "openai-completions" + current_provider.baseUrl = "https://api.moonshot.cn/v1" + current_provider.apiKey = selected_api_key + current_provider.models = { + { id = "kimi-k2.5", name = "Kimi K2.5" }, + } +end + +local override_base = os.getenv("DEFAULT_AGENT_OVERRIDE_BASE_URL") or "" +local base_url_mode = os.getenv("DEFAULT_AGENT_BASE_URL_MODE") or "default" +if base_url_mode == "override" and override_base ~= "" then + current_provider.baseUrl = override_base +elseif base_url_mode == "default" then + current_provider.baseUrl = os.getenv("DEFAULT_AGENT_BASE_URL") or current_provider.baseUrl +end + +local agents = ensure_table(cfg, "agents") +local defaults = ensure_table(agents, "defaults") +local model = ensure_table(defaults, "model") +model.primary = os.getenv("DEFAULT_AGENT_MODEL") or "anthropic/claude-sonnet-4-6" + +local encoded = json.stringify(cfg, true) +if encoded then + encoded = encoded:gsub("\\/", "/") +end +local f = assert(io.open(cfg_path, "w")) + f:write(encoded or "{}") + f:write("\n") +f:close() + +local env_path = cfg_path:gsub("openclaw%.json$", "openclaw.env") +local envf = assert(io.open(env_path, "w")) +if selected_env_key and selected_api_key ~= "" then + local val = tostring(selected_api_key) + val = val:gsub("\\", "\\\\"):gsub("\n", "\\n"):gsub('"', '\\"') + envf:write(selected_env_key .. '="' .. val .. '"\n') +end +envf:close() +EOF +} + +have_local_node_runtime() { + [ -x "$NODE_BIN" ] && [ -x "$NPM_BIN" ] +} + +cached_node_archive_ok() { + local archive="$1" + [ -s "$archive" ] || return 1 + + if command -v xz >/dev/null 2>&1; then + xz -t "$archive" >/dev/null 2>&1 + return $? + fi + + tar -tJf "$archive" >/dev/null 2>&1 +} + +install_node() { + local arch libc url tmp + arch="$(detect_arch)" + [ "$arch" != "unsupported" ] || { write_installer_log "Unsupported arch"; return 1; } + + if is_musl; then + libc="musl" + else + libc="glibc" + fi + + if [ "$libc" = "musl" ] && [ "$arch" = "linux-x64" ]; then + url="https://unofficial-builds.nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-${arch}-musl.tar.xz" + elif [ "$libc" = "musl" ] && [ "$arch" = "linux-arm64" ]; then + url="https://unofficial-builds.nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-${arch}-musl.tar.xz" + else + url="https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-${arch}.tar.xz" + fi + + if have_local_node_runtime; then + write_installer_log "Using existing Node.js runtime: $($NODE_BIN --version 2>/dev/null || echo unknown)" + return 0 + fi + + if [ -x "$NODE_BIN" ] || [ -x "$NPM_BIN" ]; then + write_installer_log "Node.js runtime under $NODE_DIR is incomplete; reinstalling bundled Node.js" + fi + + # Fallback: use system node/npm (e.g. from opkg) and symlink into BASE_DIR. + local sys_node sys_npm + sys_node="$(command -v node 2>/dev/null || true)" + sys_npm="$(command -v npm 2>/dev/null || true)" + if [ -n "$sys_node" ] && [ -n "$sys_npm" ]; then + mkdir -p "${NODE_DIR}/bin" 2>/dev/null || true + ln -sf "$sys_node" "${NODE_DIR}/bin/node" 2>/dev/null || true + ln -sf "$sys_npm" "${NODE_DIR}/bin/npm" 2>/dev/null || true + if have_local_node_runtime; then + write_installer_log "Using system Node.js: $($NODE_BIN --version 2>/dev/null || echo unknown)" + return 0 + fi + fi + + write_installer_log "Downloading Node.js v${NODE_VERSION} (${arch}, ${libc})" + tmp="/tmp/${APP}-node-${NODE_VERSION}.tar.xz" + if cached_node_archive_ok "$tmp"; then + write_installer_log "Reusing cached Node.js archive: $tmp" + else + if [ -e "$tmp" ]; then + write_installer_log "Cached Node.js archive is invalid, re-downloading: $tmp" + rm -f "$tmp" 2>/dev/null || true + fi + if ! download "$url" "$tmp"; then + write_installer_log "Node.js download failed" + return 1 + fi + fi + + rm -rf "$NODE_DIR" 2>/dev/null || true + mkdir -p "$NODE_DIR" + if command -v xz >/dev/null 2>&1; then + xz -dc "$tmp" | tar -xf - -C "$NODE_DIR" --strip-components=1 + else + tar -xJf "$tmp" -C "$NODE_DIR" --strip-components=1 + fi + rm -f "$tmp" 2>/dev/null || true + + [ -x "$NODE_BIN" ] || return 1 + write_installer_log "Node.js installed: $($NODE_BIN --version 2>/dev/null || echo unknown)" + return 0 +} + +install_openclaw() { + local flags="" npm_registry="" effective_registry="" + [ -x "$NPM_BIN" ] || return 1 + + if is_musl; then + flags="--ignore-scripts" + fi + + if [ "${INSTALL_ACCELERATED:-1}" = "1" ]; then + npm_registry="https://registry.npmmirror.com" + fi + + write_installer_log "Installing OpenClaw from npm" + write_installer_log "Node.js: $($NODE_BIN --version 2>/dev/null || echo unknown), npm: $($NPM_BIN --version 2>/dev/null || echo unknown)" + write_installer_log "npm prefix: $GLOBAL_DIR, cache: ${BASE_DIR}/npm-cache, musl: $(is_musl && echo yes || echo no) ${flags:+($flags)}" + write_installer_log "npm registry: ${npm_registry:-default}" + if [ -n "$npm_registry" ]; then + effective_registry="$(HOME="$DATA_DIR" npm_config_cache="${BASE_DIR}/npm-cache" npm_config_registry="$npm_registry" \ + PATH="${NODE_DIR}/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + "$NPM_BIN" config get registry 2>/dev/null | tr -d '\r' | head -n 1)" + else + effective_registry="$(HOME="$DATA_DIR" npm_config_cache="${BASE_DIR}/npm-cache" \ + PATH="${NODE_DIR}/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + "$NPM_BIN" config get registry 2>/dev/null | tr -d '\r' | head -n 1)" + fi + write_installer_log "npm effective registry: ${effective_registry:-unknown}" + rm -rf "${GLOBAL_DIR}/lib/node_modules/openclaw" 2>/dev/null || true + local tmp="/tmp/${APP}-npm-install.log" + rm -f "$tmp" 2>/dev/null || true + + # Some npm dependencies may be referenced as git+ssh (e.g. git@github.com:...), + # which fails on routers without SSH keys. Force git to use HTTPS by writing a + # scoped gitconfig under DATA_DIR and setting HOME for this invocation. + if command -v git >/dev/null 2>&1; then + mkdir -p "$DATA_DIR" 2>/dev/null || true + # Use --add because url.*.insteadOf is multi-valued (git config without --add overwrites). + git config --file "$DATA_DIR/.gitconfig" --add url."https://github.com/".insteadOf "ssh://git@github.com/" 2>/dev/null || true + git config --file "$DATA_DIR/.gitconfig" --add url."https://github.com/".insteadOf "git@github.com:" 2>/dev/null || true + fi + + # Stream npm output to taskd log while keeping a local copy for post-mortem. + : >"$tmp" + tail -n 0 -f "$tmp" & + local tailpid="$!" + write_installer_log "npm install started (may be quiet for a while); streaming npm output as it arrives" + + # npm from Node tarball uses `#!/usr/bin/env node`, ensure PATH contains our node. + if [ -n "$npm_registry" ]; then + HOME="$DATA_DIR" npm_config_cache="${BASE_DIR}/npm-cache" npm_config_registry="$npm_registry" \ + PATH="${NODE_DIR}/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + "$NPM_BIN" install -g "openclaw@latest" --prefix="$GLOBAL_DIR" \ + $flags --no-audit --no-fund --no-progress >"$tmp" 2>&1 & + else + HOME="$DATA_DIR" npm_config_cache="${BASE_DIR}/npm-cache" \ + PATH="${NODE_DIR}/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + "$NPM_BIN" install -g "openclaw@latest" --prefix="$GLOBAL_DIR" \ + $flags --no-audit --no-fund --no-progress >"$tmp" 2>&1 & + fi + local npmpid="$!" + + local start_ts last_change_ts last_size last_lines + start_ts="$(date +%s 2>/dev/null || echo 0)" + last_change_ts="$start_ts" + last_size=0 + last_lines=0 + + # Heartbeat: emit periodic logs so the UI doesn't appear stuck. + while kill -0 "$npmpid" 2>/dev/null; do + sleep 30 + kill -0 "$npmpid" 2>/dev/null || break + + local now_ts size lines quiet_s elapsed_s elapsed_h + now_ts="$(date +%s 2>/dev/null || echo 0)" + size="$(wc -c <"$tmp" 2>/dev/null || echo 0)" + lines="$(wc -l <"$tmp" 2>/dev/null || echo 0)" + case "$size" in ''|*[!0-9]*) size=0 ;; esac + case "$lines" in ''|*[!0-9]*) lines=0 ;; esac + + if [ "$size" -ne "$last_size" ] || [ "$lines" -ne "$last_lines" ]; then + last_size="$size" + last_lines="$lines" + last_change_ts="$now_ts" + fi + + quiet_s=$((now_ts - last_change_ts)) + elapsed_s=$((now_ts - start_ts)) + elapsed_h="$(fmt_elapsed "$elapsed_s")" + + if [ "$quiet_s" -ge 60 ]; then + local last + last="$(tail -n 1 "$tmp" 2>/dev/null | tr -d '\r' || true)" + if [ -n "$last" ]; then + write_installer_log "npm install running (${elapsed_h} elapsed; no new output for ${quiet_s}s; last: ${last})" + else + write_installer_log "npm install running (${elapsed_h} elapsed; no output yet)" + fi + fi + done + + local npm_rc=0 + wait "$npmpid" || npm_rc=$? + if [ "$npm_rc" -ne 0 ]; then + write_installer_log "npm install failed (rc=$npm_rc); dumping recent output" + kill "$tailpid" 2>/dev/null || true + wait "$tailpid" 2>/dev/null || true + tail -n 200 "$tmp" >> "$LOG_FILE" 2>/dev/null || true + rm -f "$tmp" 2>/dev/null || true + return 1 + fi + + local end_ts elapsed_s elapsed_h + end_ts="$(date +%s 2>/dev/null || echo 0)" + elapsed_s=$((end_ts - start_ts)) + elapsed_h="$(fmt_elapsed "$elapsed_s")" + write_installer_log "npm install finished (${elapsed_h} elapsed)" + kill "$tailpid" 2>/dev/null || true + wait "$tailpid" 2>/dev/null || true + tail -n 200 "$tmp" >> "$LOG_FILE" 2>/dev/null || true + rm -f "$tmp" 2>/dev/null || true + find_entry >/dev/null 2>&1 || return 1 + write_installer_log "OpenClaw installed: $(openclaw_version || echo unknown)" + return 0 +} + +do_install() { + acquire_lock + write_installer_log "== install begin ==" + ensure_dirs + if have_openclaw_runtime; then + write_installer_log "OpenClaw is already installed: $(openclaw_version || echo unknown)" + write_installer_log "Skip install. Use restart/apply config if you only need to refresh configuration." + return 0 + fi + if ! has_default_route; then + write_installer_log "Install requires internet access, but no default gateway is configured." + write_installer_log "Fix: configure WAN or add a default route, then retry Install." + return 1 + fi + check_space_mb "$BASE_DIR" 2048 || return 1 + + TOKEN="$(uci_get token)" + if [ -z "$TOKEN" ]; then + TOKEN="$(gen_token)" + uci -q set "${UCI_NS}.main.token=$TOKEN" && uci -q commit "$UCI_NS" || true + fi + + install_node + install_openclaw + ensure_gateway_config || true + + /etc/init.d/openclawmgr enable >/dev/null 2>&1 || true + uci -q set "${UCI_NS}.main.enabled=1" && uci -q commit "$UCI_NS" || true + /etc/init.d/openclawmgr restart >/dev/null 2>&1 || true + + write_installer_log "== install done ==" +} + +do_uninstall() { + acquire_lock + write_installer_log "== uninstall begin ==" + /etc/init.d/openclawmgr stop >/dev/null 2>&1 || true + /etc/init.d/openclawmgr disable >/dev/null 2>&1 || true + uci -q set "${UCI_NS}.main.enabled=0" && uci -q commit "$UCI_NS" || true + + rm -rf "$NODE_DIR" "$GLOBAL_DIR" 2>/dev/null || true + write_installer_log "Runtime removed (node/global)" + write_installer_log "== uninstall done ==" +} + +do_uninstall_openclaw() { + acquire_lock + write_installer_log "== uninstall openclaw begin ==" + /etc/init.d/openclawmgr stop >/dev/null 2>&1 || true + /etc/init.d/openclawmgr disable >/dev/null 2>&1 || true + uci -q set "${UCI_NS}.main.enabled=0" && uci -q commit "$UCI_NS" || true + + rm -rf "${GLOBAL_DIR}/lib/node_modules/openclaw" \ + "${GLOBAL_DIR}/bin/openclaw" 2>/dev/null || true + write_installer_log "OpenClaw removed (node kept)" + write_installer_log "== uninstall openclaw done ==" +} + +do_purge() { + acquire_lock + write_installer_log "== purge begin ==" + /etc/init.d/openclawmgr stop >/dev/null 2>&1 || true + /etc/init.d/openclawmgr disable >/dev/null 2>&1 || true + uci -q set "${UCI_NS}.main.enabled=0" && uci -q commit "$UCI_NS" || true + rm -rf "$BASE_DIR" 2>/dev/null || true + write_installer_log "Base dir removed: $BASE_DIR" + write_installer_log "== purge done ==" +} + +do_start() { + acquire_lock + write_installer_log "== start begin ==" + ensure_dirs + /etc/init.d/openclawmgr enable >/dev/null 2>&1 || true + uci -q set "${UCI_NS}.main.enabled=1" && uci -q commit "$UCI_NS" || true + ensure_gateway_config || true + /etc/init.d/openclawmgr restart >/dev/null 2>&1 || /etc/init.d/openclawmgr start >/dev/null 2>&1 || true + write_installer_log "== start done ==" +} + +do_stop() { + acquire_lock + write_installer_log "== stop begin ==" + /etc/init.d/openclawmgr stop >/dev/null 2>&1 || true + /etc/init.d/openclawmgr disable >/dev/null 2>&1 || true + uci -q set "${UCI_NS}.main.enabled=0" && uci -q commit "$UCI_NS" || true + write_installer_log "== stop done ==" +} + +do_restart() { + acquire_lock + write_installer_log "== restart begin ==" + ensure_dirs + ensure_gateway_config || true + /etc/init.d/openclawmgr restart >/dev/null 2>&1 || true + write_installer_log "== restart done ==" +} + +do_apply_config() { + acquire_lock + write_installer_log "== apply_config begin ==" + ensure_dirs + ensure_gateway_config || true + + local pid="" + pid="$(ubus call service list "{\"name\":\"openclawmgr\"}" 2>/dev/null | jsonfilter -e '$.openclawmgr.instances.gateway.pid' 2>/dev/null || true)" + if [ "${ENABLED:-0}" = "1" ]; then + /etc/init.d/openclawmgr enable >/dev/null 2>&1 || true + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + /etc/init.d/openclawmgr restart >/dev/null 2>&1 || true + fi + else + /etc/init.d/openclawmgr stop >/dev/null 2>&1 || true + /etc/init.d/openclawmgr disable >/dev/null 2>&1 || true + fi + + write_installer_log "== apply_config done ==" +} + +do_status() { + local entry + entry="$(find_entry 2>/dev/null || true)" + [ -n "$entry" ] || return 0 + local pid="" + pid="$(ubus call service list "{\"name\":\"openclawmgr\"}" 2>/dev/null | jsonfilter -e '$.openclawmgr.instances.gateway.pid' 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + echo "running" + return 0 + fi + echo "stopped" +} + +do_port() { echo "$PORT"; } +do_token() { echo "${TOKEN:-}"; } + +diag_paths() { + local name="$1" + DIAG_LOG="${DIAG_PREFIX}-${name}.log" + DIAG_PID="${DIAG_PREFIX}-${name}.pid" + DIAG_EXIT="${DIAG_PREFIX}-${name}.exit" + DIAG_LOCK="${DIAG_PREFIX}-${name}.lock" +} + +diag_busy() { + local name="$1" + diag_paths "$name" + [ -d "$DIAG_LOCK" ] && return 0 + return 1 +} + +diag_run() { + local name="$1" limit="${2:-200}" + case "$name" in + doctor|gateway_status|gateway_health|logs|channels_status) ;; + *) echo "unknown diag: $name" >&2; exit 1 ;; + esac + + diag_paths "$name" + if ! mkdir "$DIAG_LOCK" 2>/dev/null; then + echo "busy" >&2 + exit 2 + fi + + rm -f "$DIAG_EXIT" 2>/dev/null || true + : >"$DIAG_LOG" + echo "$$" >"$DIAG_PID" + + ( + set +e + echo "== $APP diag: $name ==" + echo "time: $(log_ts)" + echo "base_dir: $BASE_DIR" + echo "config: ${DATA_DIR}/.openclaw/openclaw.json" + echo "" + + case "$name" in + doctor) + openclaw_cmd doctor --no-color + ;; + gateway_status) + openclaw_cmd gateway status --no-color --url "ws://127.0.0.1:${PORT}" --token "${TOKEN}" + ;; + gateway_health) + openclaw_cmd gateway health --no-color --url "ws://127.0.0.1:${PORT}" --token "${TOKEN}" + ;; + logs) + case "$limit" in ''|*[!0-9]*) limit=200 ;; esac + [ "$limit" -gt 2000 ] && limit=2000 + [ "$limit" -lt 10 ] && limit=10 + openclaw_cmd logs --plain --no-color --limit "$limit" + ;; + channels_status) + openclaw_cmd channels status --no-color + ;; + esac + rc=$? + echo "" + echo "exit_code: $rc" + exit "$rc" + ) >>"$DIAG_LOG" 2>&1 + + rc=$? + echo "$rc" >"$DIAG_EXIT" + rm -f "$DIAG_PID" 2>/dev/null || true + rmdir "$DIAG_LOCK" 2>/dev/null || true + exit "$rc" +} + +diag_poll() { + local name="$1" + case "$name" in + doctor|gateway_status|gateway_health|logs|channels_status) ;; + *) echo "unknown diag: $name" >&2; exit 1 ;; + esac + + diag_paths "$name" + + local running=0 + if [ -f "$DIAG_PID" ]; then + local pid + pid="$(cat "$DIAG_PID" 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + running=1 + fi + fi + + if [ "$running" -eq 1 ]; then + echo "running" + exit 0 + fi + if [ -f "$DIAG_EXIT" ]; then + echo "done:$(cat "$DIAG_EXIT" 2>/dev/null || echo -1)" + exit 0 + fi + echo "idle" +} + +ACTION="${1:-help}" +shift 1 || true + +BASE_DIR="$(uci_get base_dir)" +PORT="$(uci_get port)" +BIND="$(uci_get bind)" +NODE_VERSION="$(uci_get node_version)" +TOKEN="$(uci_get token)" +ALLOW_INSECURE_AUTH="$(uci_get allow_insecure_auth)" +DISABLE_DEVICE_AUTH="$(uci_get disable_device_auth)" +ALLOWED_ORIGINS="$(uci_get_list allowed_origins)" +DEFAULT_AGENT="$(uci_get default_agent)" +DEFAULT_MODEL="$(uci_get default_model)" +INSTALL_ACCELERATED="$(uci_get install_accelerated)" +PROVIDER_API_KEY="$(uci_get provider_api_key)" +PROVIDER_BASE_URL="$(uci_get provider_base_url)" + +[ -n "$BASE_DIR" ] || BASE_DIR="/opt/openclawmgr" +[ -n "$PORT" ] || PORT="18789" +[ -n "$BIND" ] || BIND="lan" +[ -n "$NODE_VERSION" ] || NODE_VERSION="22.16.0" +[ -n "$ALLOW_INSECURE_AUTH" ] || ALLOW_INSECURE_AUTH="0" +[ -n "$DISABLE_DEVICE_AUTH" ] || DISABLE_DEVICE_AUTH="0" +[ -n "$DEFAULT_AGENT" ] || DEFAULT_AGENT="anthropic" +[ -n "$DEFAULT_MODEL" ] || DEFAULT_MODEL="" +[ -n "$INSTALL_ACCELERATED" ] || INSTALL_ACCELERATED="1" +[ -n "$PROVIDER_API_KEY" ] || PROVIDER_API_KEY="" +[ -n "$PROVIDER_BASE_URL" ] || PROVIDER_BASE_URL="" + +case "$PORT" in + ''|*[!0-9]*) PORT="18789" ;; +esac +case "$BIND" in + loopback|lan|auto|tailnet|custom) ;; + *) BIND="lan" ;; +esac +case "$ALLOW_INSECURE_AUTH" in + 1|true|yes|on) ALLOW_INSECURE_AUTH="1" ;; + *) ALLOW_INSECURE_AUTH="0" ;; +esac +case "$DISABLE_DEVICE_AUTH" in + 1|true|yes|on) DISABLE_DEVICE_AUTH="1" ;; + *) DISABLE_DEVICE_AUTH="0" ;; +esac +case "$DEFAULT_AGENT" in + openai|anthropic|minimax-cn|moonshot) ;; + *) DEFAULT_AGENT="anthropic" ;; +esac +case "$INSTALL_ACCELERATED" in + 1|true|yes|on) INSTALL_ACCELERATED="1" ;; + *) INSTALL_ACCELERATED="0" ;; +esac + +NODE_DIR="${BASE_DIR}/node" +GLOBAL_DIR="${BASE_DIR}/global" +DATA_DIR="${BASE_DIR}/data" +LOG_FILE="${BASE_DIR}/log/installer.log" + +NODE_BIN="${NODE_DIR}/bin/node" +NPM_BIN="${NODE_DIR}/bin/npm" + +case "$ACTION" in + install|upgrade) + do_install + ;; + uninstall) + do_uninstall + ;; + uninstall_openclaw) + do_uninstall_openclaw + ;; + purge) + do_purge + ;; + rm) + do_uninstall + ;; + start) + do_start + ;; + stop) + do_stop + ;; + restart) + do_restart + ;; + apply_config) + do_apply_config + ;; + status) + do_status + ;; + port) + do_port + ;; + token) + do_token + ;; + node_version) + node_version + ;; + openclaw_version) + openclaw_version + ;; + diag) + diag_run "${1:-}" "${2:-}" + ;; + diag_poll) + diag_poll "${1:-}" + ;; + *) + echo "Usage: $0 {install|upgrade|uninstall|uninstall_openclaw|purge|rm|start|stop|restart|status|port|token|node_version|openclaw_version|diag|diag_poll}" >&2 + exit 1 + ;; +esac diff --git a/luci-app-openclawmgr/root/usr/share/rpcd/acl.d/luci-app-openclawmgr.json b/luci-app-openclawmgr/root/usr/share/rpcd/acl.d/luci-app-openclawmgr.json new file mode 100644 index 00000000..0867ced0 --- /dev/null +++ b/luci-app-openclawmgr/root/usr/share/rpcd/acl.d/luci-app-openclawmgr.json @@ -0,0 +1,11 @@ +{ + "luci-app-openclawmgr": { + "description": "Grant UCI access for luci-app-openclawmgr", + "read": { + "uci": [ "openclawmgr" ] + }, + "write": { + "uci": [ "openclawmgr" ] + } + } +} diff --git a/mtproxy/Makefile b/mtproxy/Makefile index 22eca2d6..b23ead98 100644 --- a/mtproxy/Makefile +++ b/mtproxy/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=mtproxy -PKG_VERSION:=3.0.10 -PKG_RELEASE:=1 +PKG_VERSION:=3.0.11 +PKG_RELEASE:=2 PKG_MAINTAINER:=Kosntantine Shevlakov PKG_LICENSE:=GPLv2 @@ -10,7 +10,7 @@ PKG_LICENSE_FILES:=LICENSE PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/GetPageSpeed/MTProxy.git -PKG_SOURCE_VERSION:=4333cafad15acaf6f627b23c852ef3a4e4b68ae0 +PKG_SOURCE_VERSION:=eab5096fb539bb63d323e81c03130d408591cf41 PKG_SOURCE_SUBDIR:=$(PKG_NAME) PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz