feat(storage): support configurable install root

Add a LuCI install-root input, persist the selected path in UCI,
and route install, status, backup, uninstall, and runtime scripts
through the configured storage root for new installs.

Reference: custom install root flow
This commit is contained in:
2026-03-18 13:48:07 +08:00
parent ee10bb0bd5
commit 68f24e6658
17 changed files with 739 additions and 122 deletions

View File

@@ -56,8 +56,12 @@ define Package/$(PKG_NAME)/install
$(INSTALL_DATA) ./root/etc/profile.d/openclaw.sh $(1)/etc/profile.d/openclaw.sh
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) ./root/usr/bin/openclaw-env $(1)/usr/bin/openclaw-env
$(INSTALL_DIR) $(1)/usr/libexec
$(INSTALL_BIN) ./root/usr/libexec/openclaw-paths.sh $(1)/usr/libexec/openclaw-paths.sh
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/controller
$(INSTALL_DATA) ./luasrc/controller/openclaw.lua $(1)/usr/lib/lua/luci/controller/openclaw.lua
$(INSTALL_DIR) $(1)/usr/lib/lua/openclaw
$(INSTALL_DATA) ./luasrc/openclaw/paths.lua $(1)/usr/lib/lua/openclaw/paths.lua
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/model/cbi/openclaw
$(INSTALL_DATA) ./luasrc/model/cbi/openclaw/basic.lua $(1)/usr/lib/lua/luci/model/cbi/openclaw/basic.lua
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/view/openclaw

View File

@@ -23,6 +23,8 @@
| 存储 | **1.5GB 以上可用空间** |
| 内存 | 推荐 1GB 及以上 |
安装运行环境时可在 LuCI 弹窗中自定义“安装根目录 / 检测目录”,例如填写已挂载的 eMMC 路径 `/mnt/emmc`。OpenClaw 会自动把实际文件放到 `<路径>/openclaw/` 下。
## 📦 安装
### 方式一:.run 自解压包(推荐)
@@ -82,6 +84,9 @@ cp -r root/* /
mkdir -p /usr/lib/lua/luci/controller /usr/lib/lua/luci/model/cbi/openclaw /usr/lib/lua/luci/view/openclaw
cp luasrc/controller/openclaw.lua /usr/lib/lua/luci/controller/
cp luasrc/model/cbi/openclaw/*.lua /usr/lib/lua/luci/model/cbi/openclaw/
mkdir -p /usr/lib/lua/openclaw /usr/libexec
cp luasrc/openclaw/paths.lua /usr/lib/lua/openclaw/
cp root/usr/libexec/openclaw-paths.sh /usr/libexec/
cp luasrc/view/openclaw/*.htm /usr/lib/lua/luci/view/openclaw/
chmod +x /etc/init.d/openclaw /usr/bin/openclaw-env /usr/share/openclaw/oc-config.sh
@@ -92,7 +97,7 @@ rm -f /tmp/luci-indexcache /tmp/luci-modulecache/*
## 🔰 首次使用
1. 打开 LuCI → 服务 → OpenClaw点击「安装运行环境」
1. 打开 LuCI → 服务 → OpenClaw点击「安装运行环境」,按需填写安装根目录,例如 `/mnt/emmc`
2. 安装完成后服务会自动启动,点击「刷新页面」查看状态
3. 进入「Web 控制台」添加 AI 模型和 API Key
4. 进入「配置管理」可使用向导配置消息渠道

View File

@@ -0,0 +1,144 @@
# Custom Install Root Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let new OpenClaw installs choose a LuCI-specified storage root and keep all runtime files under that configured root.
**Architecture:** Add a persisted UCI install-root setting, a shared shell path helper, and a small Lua path helper so UI checks and runtime scripts all derive the same directories. Keep `/opt` as the default and preserve the existing `/openclaw/{node,global,data}` subtree under whichever root the user selects.
**Tech Stack:** LuCI Lua, POSIX shell, Node.js runtime scripts, OpenWrt UCI
---
### Task 1: Add failing path derivation tests
**Files:**
- Create: `tests/test_openclaw_paths.lua`
- Create: `luasrc/openclaw/paths.lua`
- [ ] **Step 1: Write the failing test**
Create Lua assertions for:
- default root -> `/opt`
- `/mnt/emmc/` -> `/mnt/emmc`
- derived paths under `<root>/openclaw`
- invalid relative paths fall back to `/opt`
- [ ] **Step 2: Run test to verify it fails**
Run: `lua /Users/lingyuzeng/project/luci-app-openclaw/tests/test_openclaw_paths.lua`
Expected: FAIL because `luasrc/openclaw/paths.lua` does not exist yet.
- [ ] **Step 3: Write minimal implementation**
Add a pure-Lua helper exposing normalization and derived-path functions.
- [ ] **Step 4: Run test to verify it passes**
Run: `lua /Users/lingyuzeng/project/luci-app-openclaw/tests/test_openclaw_paths.lua`
Expected: PASS
### Task 2: Add shared shell path helper
**Files:**
- Create: `root/usr/libexec/openclaw-paths.sh`
- Modify: `Makefile`
- [ ] **Step 1: Write the failing test**
Extend the Lua test or add a shell smoke command that expects the helper to emit normalized install root and derived directories.
- [ ] **Step 2: Run test to verify it fails**
Run: `sh -c '. /Users/lingyuzeng/project/luci-app-openclaw/root/usr/libexec/openclaw-paths.sh; oc_load_paths'`
Expected: FAIL because the helper does not exist yet.
- [ ] **Step 3: Write minimal implementation**
Implement helper functions for normalization, derived directories, and `/opt`-specific OverlayFS handling guards. Install the helper in the package Makefile.
- [ ] **Step 4: Run test to verify it passes**
Run: `sh -n /Users/lingyuzeng/project/luci-app-openclaw/root/usr/libexec/openclaw-paths.sh`
Expected: PASS
### Task 3: Wire LuCI dialog and APIs
**Files:**
- Modify: `luasrc/model/cbi/openclaw/basic.lua`
- Modify: `luasrc/controller/openclaw.lua`
- Modify: `root/etc/config/openclaw`
- [ ] **Step 1: Add failing coverage mindset**
Use the existing Lua path test as the guardrail for normalization and manually confirm current UI/API code still hard-codes `/opt`.
- [ ] **Step 2: Implement API and dialog changes**
Add:
- UCI-backed default install root
- dialog input and explanatory copy
- `check_system` support for `install_root`
- `setup` support for persisting the root and refusing live path switches
- [ ] **Step 3: Verify behavior**
Run:
- `lua -e 'assert(loadfile("/Users/lingyuzeng/project/luci-app-openclaw/luasrc/controller/openclaw.lua"))'`
- `lua -e 'assert(loadfile("/Users/lingyuzeng/project/luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua"))'`
Expected: PASS
### Task 4: Route runtime scripts through the configured root
**Files:**
- Modify: `root/usr/bin/openclaw-env`
- Modify: `root/etc/init.d/openclaw`
- Modify: `root/etc/profile.d/openclaw.sh`
- Modify: `root/etc/uci-defaults/99-openclaw`
- Modify: `root/usr/share/openclaw/oc-config.sh`
- [ ] **Step 1: Update scripts**
Source the shared shell helper, derive paths from UCI or `OPENCLAW_INSTALL_ROOT`, and keep the `/opt` workaround only for the default root.
- [ ] **Step 2: Verify syntax**
Run:
- `sh -n /Users/lingyuzeng/project/luci-app-openclaw/root/usr/bin/openclaw-env`
- `sh -n /Users/lingyuzeng/project/luci-app-openclaw/root/etc/init.d/openclaw`
- `sh -n /Users/lingyuzeng/project/luci-app-openclaw/root/etc/profile.d/openclaw.sh`
- `sh -n /Users/lingyuzeng/project/luci-app-openclaw/root/etc/uci-defaults/99-openclaw`
- `sh -n /Users/lingyuzeng/project/luci-app-openclaw/root/usr/share/openclaw/oc-config.sh`
Expected: PASS
### Task 5: Fix remaining controller/runtime path call sites and verify
**Files:**
- Modify: `luasrc/controller/openclaw.lua`
- Modify: `luasrc/model/cbi/openclaw/basic.lua`
- Modify: `README.md`
- [ ] **Step 1: Replace remaining hard-coded `/opt/openclaw` references used by user-facing flows**
Cover status, uninstall, backup/restore, error hints, and install dialog copy.
- [ ] **Step 2: Run full verification**
Run:
- `lua /Users/lingyuzeng/project/luci-app-openclaw/tests/test_openclaw_paths.lua`
- `lua -e 'assert(loadfile("/Users/lingyuzeng/project/luci-app-openclaw/luasrc/controller/openclaw.lua"))'`
- `lua -e 'assert(loadfile("/Users/lingyuzeng/project/luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua"))'`
- `rg -n "/opt/openclaw" /Users/lingyuzeng/project/luci-app-openclaw`
Expected:
- tests and syntax checks PASS
- remaining `/opt/openclaw` hits are limited to historical docs or intentional compatibility text
- [ ] **Step 3: Commit**
```bash
git -C /Users/lingyuzeng/project/luci-app-openclaw add .
git -C /Users/lingyuzeng/project/luci-app-openclaw commit -m "feat: support configurable install root"
```

View File

@@ -0,0 +1,85 @@
# Custom Install Root Design
## Goal
Allow new OpenClaw installations to choose a user-specified storage root from the LuCI install dialog, use that path for pre-install disk checks, and store all runtime files under the chosen root instead of forcing `/opt`.
## Scope
- Add an install-root input to the LuCI install dialog with guidance for mounted eMMC paths such as `/mnt/emmc`.
- Persist the selected root in UCI so follow-up actions use the same location.
- Route installation, runtime startup, status checks, backup/restore, and uninstall through the configured root.
- Keep the behavior migration-free: changing the root does not move existing `/opt/openclaw` data.
## Non-Goals
- Automatic migration of an existing installation between storage roots.
- Support for relative paths or multiple active installation roots.
- Reworking historical docs that describe `/opt/openclaw` beyond the most user-facing references touched by this feature.
## Design
### Configuration model
Persist a new UCI option `openclaw.main.install_root`, defaulting to `/opt`. The value represents the parent mount path chosen by the user. Runtime directories continue to be derived in a fixed layout:
- `<install_root>/openclaw/node`
- `<install_root>/openclaw/global`
- `<install_root>/openclaw/data`
This preserves the existing internal layout while letting the outer storage location move to eMMC or other mounted storage.
### UI behavior
The install dialog will expose an "安装根目录 / 检测目录" input with inline guidance that users should enter the mounted storage path, for example `/mnt/emmc`, and that OpenClaw files will be created under `<path>/openclaw/`.
On confirm:
- The frontend sends the selected root to the system-check API.
- The frontend shows both the detection path and the actual install path in the log panel.
- The same root is sent to the setup API.
If the runtime is already installed and the requested root differs from the configured root, setup should refuse and instruct the user to uninstall first. That enforces the "new installs only" rule.
### Path resolution
Introduce a shared shell helper for runtime scripts plus a small Lua helper for controller code. Both normalize the configured install root by:
- Requiring an absolute path.
- Trimming trailing slashes except for `/`.
- Falling back to `/opt` when the value is empty or invalid.
Shell scripts consume the helper directly. Lua code uses the Lua helper so controller actions can derive paths without hard-coding `/opt/openclaw`.
### System check
The system-check API accepts an optional `install_root` parameter. It checks:
- Total physical memory against the existing 1024 MB threshold.
- Disk space on the requested root, or the nearest existing ancestor when the exact directory does not exist yet.
The API returns the normalized install root, actual OpenClaw install path, and the path used for `df`.
### Runtime changes
All runtime entry points should use the configured root:
- `openclaw-env`
- init script
- profile environment
- config TTY script
- controller actions for status, backup, restore, uninstall
The `/opt` OverlayFS workaround remains only when the configured install root is `/opt`.
## Verification
- Add lightweight tests for path normalization and derived path layout.
- Run the path tests locally.
- Run Lua syntax checks on modified Lua files.
- Run shell syntax checks on modified shell scripts.
## Risks
- Existing users can still repoint the setting without migration if they force a new install path after uninstalling. That is acceptable for this request but should be documented in UI copy.
- Backup/restore and uninstall touch many path call sites, so a missed hard-coded `/opt/openclaw` reference would cause partial regressions. Search-based verification is required before completion.

View File

@@ -1,6 +1,86 @@
-- luci-app-openclaw — LuCI Controller
module("luci.controller.openclaw", package.seeall)
local nixio_fs = require "nixio.fs"
local util = require "luci.util"
local oc_paths = require "openclaw.paths"
local function get_install_root_from_uci()
return require("luci.model.uci").cursor():get("openclaw", "main", "install_root")
end
local function get_runtime_paths(install_root)
return oc_paths.derive_paths(install_root or get_install_root_from_uci())
end
local function trim(value)
if type(value) ~= "string" then
return ""
end
return value:gsub("^%s+", ""):gsub("%s+$", "")
end
local function normalize_requested_install_root(value)
local cleaned = trim(value)
if cleaned == "" then
return true, get_runtime_paths().install_root
end
if cleaned:sub(1, 1) ~= "/" then
return false, nil, "安装根目录必须使用绝对路径,例如 /mnt/emmc"
end
if cleaned:match("%s") then
return false, nil, "安装根目录不能包含空白字符"
end
return true, oc_paths.normalize_install_root(cleaned)
end
local function get_node_bin(paths)
return paths.node_base .. "/bin/node"
end
local function get_config_file(paths)
return paths.oc_data .. "/.openclaw/openclaw.json"
end
local function shell_quote(value)
return util.shellquote(value or "")
end
local function path_type(path)
return nixio_fs.stat(path, "type")
end
local function directory_exists(path)
return path_type(path) == "dir"
end
local function file_exists(path)
return path_type(path) ~= nil
end
local function find_oc_entry(paths)
local search_dirs = {
paths.oc_global .. "/lib/node_modules/openclaw",
paths.oc_global .. "/node_modules/openclaw",
paths.node_base .. "/lib/node_modules/openclaw",
}
for _, dir_path in ipairs(search_dirs) do
if file_exists(dir_path .. "/openclaw.mjs") then
return dir_path .. "/openclaw.mjs", dir_path
elseif file_exists(dir_path .. "/dist/cli.js") then
return dir_path .. "/dist/cli.js", dir_path
end
end
return "", ""
end
local function runtime_installed(paths)
local oc_entry = find_oc_entry(paths)
return file_exists(get_node_bin(paths)) and oc_entry ~= ""
end
function index()
-- 主入口: 服务 → OpenClaw (🧠 作为菜单图标)
local page = entry({"admin", "services", "openclaw"}, alias("admin", "services", "openclaw", "basic"), _("OpenClaw"), 90)
@@ -53,6 +133,7 @@ function action_status()
local http = require "luci.http"
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local paths = get_runtime_paths()
local port = uci:get("openclaw", "main", "port") or "18789"
local pty_port = uci:get("openclaw", "main", "pty_port") or "18793"
@@ -75,6 +156,8 @@ function action_status()
node_version = "",
oc_version = "",
plugin_version = "",
install_root = paths.install_root,
oc_root = paths.oc_root,
}
-- 插件版本
@@ -87,7 +170,7 @@ function action_status()
-- 安装方式检测 (离线 / 在线)
-- 检查 Node.js
local node_bin = "/opt/openclaw/node/bin/node"
local node_bin = get_node_bin(paths)
local f = io.open(node_bin, "r")
if f then
f:close()
@@ -97,9 +180,9 @@ function action_status()
-- OpenClaw 版本 (从 package.json 读取)
local oc_dirs = {
"/opt/openclaw/global/lib/node_modules/openclaw",
"/opt/openclaw/global/node_modules/openclaw",
"/opt/openclaw/node/lib/node_modules/openclaw",
paths.oc_global .. "/lib/node_modules/openclaw",
paths.oc_global .. "/node_modules/openclaw",
paths.node_base .. "/lib/node_modules/openclaw",
}
for _, d in ipairs(oc_dirs) do
local pf = io.open(d .. "/package.json", "r")
@@ -132,7 +215,7 @@ function action_status()
result.pty_running = (tonumber(pty_check) or 0) > 0
-- 读取当前活跃模型
local config_file = "/opt/openclaw/data/.openclaw/openclaw.json"
local config_file = get_config_file(paths)
local cf = io.open(config_file, "r")
if cf then
local content = cf:read("*a")
@@ -202,6 +285,7 @@ end
function action_service_ctl()
local http = require "luci.http"
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local action = http.formvalue("action") or ""
@@ -221,26 +305,59 @@ function action_service_ctl()
elseif action == "disable" then
sys.exec("/etc/init.d/openclaw disable 2>/dev/null")
elseif action == "setup" then
local valid_root, requested_install_root, root_error = normalize_requested_install_root(http.formvalue("install_root"))
if not valid_root then
http.prepare_content("application/json")
http.write_json({ status = "error", message = root_error })
return
end
local requested_paths = get_runtime_paths(requested_install_root)
local current_paths = get_runtime_paths()
if not directory_exists(requested_paths.install_root) then
http.prepare_content("application/json")
http.write_json({
status = "error",
message = "检测目录不存在: " .. requested_paths.install_root .. "。请先挂载或创建该目录后再安装。"
})
return
end
if runtime_installed(current_paths) and requested_paths.install_root ~= current_paths.install_root then
http.prepare_content("application/json")
http.write_json({
status = "error",
message = "当前已安装在 " .. current_paths.oc_root .. "。如需更换目录,请先卸载环境后再重新安装。"
})
return
end
-- 先清理旧日志和状态
sys.exec("rm -f /tmp/openclaw-setup.log /tmp/openclaw-setup.pid /tmp/openclaw-setup.exit")
-- 获取用户选择的版本 (stable=指定版本, latest=最新版)
local version = http.formvalue("version") or ""
local env_prefix = ""
local env_parts = {
"OPENCLAW_INSTALL_ROOT=" .. shell_quote(requested_paths.install_root)
}
if version == "stable" then
-- 稳定版: 读取 openclaw-env 中定义的 OC_TESTED_VERSION
local tested_ver = sys.exec("grep '^OC_TESTED_VERSION=' /usr/bin/openclaw-env 2>/dev/null | cut -d'\"' -f2"):gsub("%s+", "")
if tested_ver ~= "" then
env_prefix = "OC_VERSION=" .. tested_ver .. " "
table.insert(env_parts, 1, "OC_VERSION=" .. shell_quote(tested_ver))
end
elseif version ~= "" and version ~= "latest" then
-- 校验版本号格式 (仅允许数字、点、横线、字母)
if version:match("^[%d%.%-a-zA-Z]+$") then
env_prefix = "OC_VERSION=" .. version .. " "
table.insert(env_parts, 1, "OC_VERSION=" .. shell_quote(version))
end
end
uci:set("openclaw", "main", "install_root", requested_paths.install_root)
uci:commit("openclaw")
-- 后台安装,成功后自动启用并启动服务
-- 注: openclaw-env 脚本有 set -einit_openclaw 中的非关键失败不应阻止启动
sys.exec("( " .. env_prefix .. "/usr/bin/openclaw-env setup > /tmp/openclaw-setup.log 2>&1; RC=$?; echo $RC > /tmp/openclaw-setup.exit; if [ $RC -eq 0 ]; then uci set openclaw.main.enabled=1; uci commit openclaw; /etc/init.d/openclaw enable 2>/dev/null; sleep 1; /etc/init.d/openclaw start >> /tmp/openclaw-setup.log 2>&1; fi ) & echo $! > /tmp/openclaw-setup.pid")
sys.exec("( " .. table.concat(env_parts, " ") .. " /usr/bin/openclaw-env setup > /tmp/openclaw-setup.log 2>&1; RC=$?; echo $RC > /tmp/openclaw-setup.exit; if [ $RC -eq 0 ]; then uci set openclaw.main.enabled=1; uci commit openclaw; /etc/init.d/openclaw enable 2>/dev/null; sleep 1; /etc/init.d/openclaw start >> /tmp/openclaw-setup.log 2>&1; fi ) & echo $! > /tmp/openclaw-setup.pid")
http.prepare_content("application/json")
http.write_json({ status = "ok", message = "安装已启动,请查看安装日志..." })
return
@@ -368,6 +485,7 @@ end
function action_uninstall()
local http = require "luci.http"
local sys = require "luci.sys"
local paths = get_runtime_paths()
-- 停止服务
sys.exec("/etc/init.d/openclaw stop >/dev/null 2>&1")
@@ -376,7 +494,7 @@ function action_uninstall()
-- 设置 UCI enabled=0
sys.exec("uci set openclaw.main.enabled=0; uci commit openclaw 2>/dev/null")
-- 删除 Node.js + OpenClaw 运行环境 (包含所有插件: qqbot, 飞书等)
sys.exec("rm -rf /opt/openclaw")
sys.exec("rm -rf " .. shell_quote(paths.oc_root))
-- 清理旧数据迁移后可能残留的目录
sys.exec("rm -rf /root/.openclaw 2>/dev/null")
-- 清理临时文件
@@ -389,7 +507,7 @@ function action_uninstall()
http.prepare_content("application/json")
http.write_json({
status = "ok",
message = "运行环境已卸载。已清理: Node.js 运行环境 (/opt/openclaw)、所有插件 (qqbot/飞书等)、旧数据目录 (/root/.openclaw)、临时文件、LuCI 缓存。"
message = "运行环境已卸载。已清理: Node.js 运行环境 (" .. paths.oc_root .. ")、所有插件 (qqbot/飞书等)、旧数据目录 (/root/.openclaw)、临时文件、LuCI 缓存。"
})
end
@@ -545,25 +663,10 @@ function action_backup()
local http = require "luci.http"
local sys = require "luci.sys"
local action = http.formvalue("action") or "create"
local paths = get_runtime_paths()
local node_bin = "/opt/openclaw/node/bin/node"
local oc_entry = ""
-- 查找 openclaw 入口
local search_dirs = {
"/opt/openclaw/global/lib/node_modules/openclaw",
"/opt/openclaw/global/node_modules/openclaw",
"/opt/openclaw/node/lib/node_modules/openclaw",
}
for _, d in ipairs(search_dirs) do
if nixio.fs.stat(d .. "/openclaw.mjs", "type") then
oc_entry = d .. "/openclaw.mjs"
break
elseif nixio.fs.stat(d .. "/dist/cli.js", "type") then
oc_entry = d .. "/dist/cli.js"
break
end
end
local node_bin = get_node_bin(paths)
local oc_entry = find_oc_entry(paths)
if oc_entry == "" then
http.prepare_content("application/json")
@@ -572,21 +675,29 @@ function action_backup()
end
local env_prefix = string.format(
"HOME=/opt/openclaw/data OPENCLAW_HOME=/opt/openclaw/data " ..
"OPENCLAW_STATE_DIR=/opt/openclaw/data/.openclaw " ..
"OPENCLAW_CONFIG_PATH=/opt/openclaw/data/.openclaw/openclaw.json " ..
"PATH=/opt/openclaw/node/bin:/opt/openclaw/global/bin:$PATH "
"OPENCLAW_INSTALL_ROOT=%s " ..
"HOME=%s OPENCLAW_HOME=%s " ..
"OPENCLAW_STATE_DIR=%s " ..
"OPENCLAW_CONFIG_PATH=%s " ..
"PATH=%s:%s:$PATH ",
shell_quote(paths.install_root),
shell_quote(paths.oc_data),
shell_quote(paths.oc_data),
shell_quote(paths.oc_data .. "/.openclaw"),
shell_quote(get_config_file(paths)),
shell_quote(paths.node_base .. "/bin"),
shell_quote(paths.oc_global .. "/bin")
)
-- 备份目录 (openclaw backup create 输出到 CWD需要 cd)
local backup_dir = "/opt/openclaw/data/.openclaw/backups"
local cd_prefix = "mkdir -p " .. backup_dir .. " && cd " .. backup_dir .. " && "
local backup_dir = paths.oc_data .. "/.openclaw/backups"
local cd_prefix = "mkdir -p " .. shell_quote(backup_dir) .. " && cd " .. shell_quote(backup_dir) .. " && "
-- ── 辅助: 解析单个备份文件的 manifest 信息 ──
local function parse_backup_info(filepath)
local filename = filepath:match("([^/]+)$") or filepath
-- 文件大小
local st = nixio.fs.stat(filepath)
local st = nixio_fs.stat(filepath)
local size = st and st.size or 0
-- 从文件名提取时间戳: 2026-03-11T18-28-43.149Z-openclaw-backup.tar.gz
local ts = filename:match("^(%d%d%d%d%-%d%d%-%d%dT%d%d%-%d%d%-%d%d%.%d+Z)")
@@ -598,7 +709,7 @@ function action_backup()
-- 读取 manifest.json 判断备份类型
local backup_type = "unknown"
local manifest_json = sys.exec(
"tar --wildcards -xzf " .. filepath .. " '*/manifest.json' -O 2>/dev/null"
"tar --wildcards -xzf " .. shell_quote(filepath) .. " '*/manifest.json' -O 2>/dev/null"
)
if manifest_json and manifest_json ~= "" then
-- 简单字符串匹配,避免依赖 JSON 库
@@ -640,13 +751,13 @@ function action_backup()
local only_config = http.formvalue("only_config") or "1"
local backup_cmd
if only_config == "1" then
backup_cmd = cd_prefix .. env_prefix .. node_bin .. " " .. oc_entry .. " backup create --only-config --no-include-workspace 2>&1"
backup_cmd = cd_prefix .. env_prefix .. shell_quote(node_bin) .. " " .. shell_quote(oc_entry) .. " backup create --only-config --no-include-workspace 2>&1"
else
backup_cmd = cd_prefix .. "HOME=" .. backup_dir .. " " .. env_prefix .. node_bin .. " " .. oc_entry .. " backup create --no-include-workspace 2>&1"
backup_cmd = cd_prefix .. "HOME=" .. shell_quote(backup_dir) .. " " .. env_prefix .. shell_quote(node_bin) .. " " .. shell_quote(oc_entry) .. " backup create --no-include-workspace 2>&1"
end
local output = sys.exec(backup_cmd)
-- 完整备份可能输出到 HOME移动到 backup_dir
sys.exec("mv /opt/openclaw/data/*-openclaw-backup.tar.gz " .. backup_dir .. "/ 2>/dev/null")
sys.exec("mv " .. shell_quote(paths.oc_data) .. "/*-openclaw-backup.tar.gz " .. shell_quote(backup_dir .. "/") .. " 2>/dev/null")
-- 提取备份文件路径
local backup_path = output:match("([%S]+%.tar%.gz)")
http.prepare_content("application/json")
@@ -658,13 +769,13 @@ function action_backup()
})
elseif action == "verify" then
-- 找到最新的备份文件
local latest = sys.exec("ls -t " .. backup_dir .. "/*-openclaw-backup.tar.gz 2>/dev/null | head -1"):gsub("%s+", "")
local latest = sys.exec("ls -t " .. shell_quote(backup_dir) .. "/*-openclaw-backup.tar.gz 2>/dev/null | head -1"):gsub("%s+", "")
if latest == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "未找到备份文件,请先创建备份" })
return
end
local output = sys.exec(env_prefix .. node_bin .. " " .. oc_entry .. " backup verify " .. latest .. " 2>&1")
local output = sys.exec(env_prefix .. shell_quote(node_bin) .. " " .. shell_quote(oc_entry) .. " backup verify " .. shell_quote(latest) .. " 2>&1")
http.prepare_content("application/json")
http.write_json({
status = "ok",
@@ -683,20 +794,20 @@ function action_backup()
restore_path = backup_dir .. "/" .. target_file
end
end
if restore_path == "" or not nixio.fs.stat(restore_path, "type") then
if restore_path == "" or not nixio_fs.stat(restore_path, "type") then
-- fallback 到最新
restore_path = sys.exec("ls -t " .. backup_dir .. "/*-openclaw-backup.tar.gz 2>/dev/null | head -1"):gsub("%s+", "")
restore_path = sys.exec("ls -t " .. shell_quote(backup_dir) .. "/*-openclaw-backup.tar.gz 2>/dev/null | head -1"):gsub("%s+", "")
end
if restore_path == "" then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "未找到备份文件,请先创建备份" })
return
end
local oc_data_dir = "/opt/openclaw/data/.openclaw"
local oc_data_dir = paths.oc_data .. "/.openclaw"
local config_path = oc_data_dir .. "/openclaw.json"
-- 1) 先验证备份中的 openclaw.json 是否有效
local check_cmd = "tar -xzf " .. restore_path .. " --wildcards '*/openclaw.json' -O 2>/dev/null"
local check_cmd = "tar -xzf " .. shell_quote(restore_path) .. " --wildcards '*/openclaw.json' -O 2>/dev/null"
local json_content = sys.exec(check_cmd)
if not json_content or json_content == "" then
http.prepare_content("application/json")
@@ -707,7 +818,7 @@ function action_backup()
local tmpfile = "/tmp/oc-restore-check.json"
local f = io.open(tmpfile, "w")
if f then f:write(json_content); f:close() end
local check = sys.exec(node_bin .. " -e \"try{JSON.parse(require('fs').readFileSync('" .. tmpfile .. "','utf8'));console.log('OK')}catch(e){console.log('FAIL')}\" 2>/dev/null"):gsub("%s+", "")
local check = sys.exec(shell_quote(node_bin) .. " -e \"try{JSON.parse(require('fs').readFileSync('" .. tmpfile .. "','utf8'));console.log('OK')}catch(e){console.log('FAIL')}\" 2>/dev/null"):gsub("%s+", "")
os.remove(tmpfile)
if check ~= "OK" then
http.prepare_content("application/json")
@@ -716,11 +827,11 @@ function action_backup()
end
-- 2) 备份当前配置
sys.exec("cp -f " .. config_path .. " " .. config_path .. ".pre-restore 2>/dev/null")
sys.exec("cp -f " .. shell_quote(config_path) .. " " .. shell_quote(config_path .. ".pre-restore") .. " 2>/dev/null")
-- 3) 获取备份名前缀 (如: 2026-03-11T18-21-17.209Z-openclaw-backup)
-- 备份结构: <backup_name>/payload/posix/<绝对路径>
local first_entry = sys.exec("tar -tzf " .. restore_path .. " 2>/dev/null | head -1"):gsub("%s+", "")
local first_entry = sys.exec("tar -tzf " .. shell_quote(restore_path) .. " 2>/dev/null | head -1"):gsub("%s+", "")
local backup_name = first_entry:match("^([^/]+)/") or ""
if backup_name == "" then
http.prepare_content("application/json")
@@ -741,12 +852,12 @@ function action_backup()
-- 使用精确路径前缀代替 wildcards
local extract_cmd = string.format(
"tar -xzf %s --strip-components=%d -C / '%s' 2>&1",
restore_path, strip_count, payload_prefix
shell_quote(restore_path), strip_count, payload_prefix
)
local extract_out = sys.exec(extract_cmd)
-- 6) 修复权限
sys.exec("chown -R openclaw:openclaw " .. oc_data_dir .. " 2>/dev/null")
sys.exec("chown -R openclaw:openclaw " .. shell_quote(oc_data_dir) .. " 2>/dev/null")
-- 7) 重启服务
sys.exec("/etc/init.d/openclaw start >/dev/null 2>&1 &")
@@ -761,7 +872,7 @@ function action_backup()
})
elseif action == "list" then
-- 返回结构化的备份文件列表(含类型/大小/时间)
local files_raw = sys.exec("ls -t " .. backup_dir .. "/*-openclaw-backup.tar.gz 2>/dev/null"):gsub("%s+$", "")
local files_raw = sys.exec("ls -t " .. shell_quote(backup_dir) .. "/*-openclaw-backup.tar.gz 2>/dev/null"):gsub("%s+$", "")
local backups = {}
if files_raw ~= "" then
for fpath in files_raw:gmatch("[^\n]+") do
@@ -789,7 +900,7 @@ function action_backup()
return
end
local del_path = backup_dir .. "/" .. target_file
if not nixio.fs.stat(del_path, "type") then
if not nixio_fs.stat(del_path, "type") then
http.prepare_content("application/json")
http.write_json({ status = "error", message = "备份文件不存在" })
return
@@ -815,10 +926,12 @@ end
function action_check_system()
local http = require "luci.http"
local sys = require "luci.sys"
local valid_root, install_root, root_error = normalize_requested_install_root(http.formvalue("install_root"))
-- 最低要求配置
local MIN_MEMORY_MB = 1024 -- 1GB
local MIN_DISK_MB = 1536 -- 1.5GB
local paths = get_runtime_paths(install_root)
local result = {
memory_mb = 0,
@@ -826,10 +939,19 @@ function action_check_system()
disk_mb = 0,
disk_ok = false,
disk_path = "",
install_root = paths.install_root,
oc_root = paths.oc_root,
pass = false,
message = ""
}
if not valid_root then
result.message = root_error
http.prepare_content("application/json")
http.write_json(result)
return
end
-- 检测总内存 (从 /proc/meminfo 读取 MemTotal)
local meminfo = io.open("/proc/meminfo", "r")
if meminfo then
@@ -845,17 +967,16 @@ function action_check_system()
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 directory_exists(paths.install_root) then
local df_output = sys.exec("df -m " .. shell_quote(paths.install_root) .. " 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
result.disk_path = paths.install_root
end
result.disk_ok = result.disk_mb >= MIN_DISK_MB
else
result.message = "检测目录不存在: " .. paths.install_root .. "。请先挂载或创建该目录。"
end
-- 综合判断
result.pass = result.memory_ok and result.disk_ok
@@ -865,10 +986,13 @@ function action_check_system()
result.message = "系统配置检测通过"
else
local issues = {}
if result.message ~= "" then
table.insert(issues, result.message)
end
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
if result.message == "" and 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, "")

View File

@@ -1,5 +1,11 @@
-- luci-app-openclaw — 基本设置 CBI Model
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local oc_paths = require "openclaw.paths"
local current_paths = oc_paths.derive_paths(uci:get("openclaw", "main", "install_root"))
local current_install_root = current_paths.install_root
local current_oc_root = current_paths.oc_root
m = Map("openclaw", "OpenClaw AI 网关",
"OpenClaw 是一个 AI 编程代理网关,支持 GitHub Copilot、Claude、GPT、Gemini 等大模型以及 QQ、Telegram、Discord 等多种消息渠道。")
@@ -41,6 +47,7 @@ act.cfgvalue = function(self, section)
html[#html+1] = '</div>'
html[#html+1] = '<div id="action-result" style="margin-top:8px;"></div>'
html[#html+1] = '<div id="oc-update-action" style="margin-top:8px;display:none;"></div>'
html[#html+1] = '<div style="margin:4px 0 8px;color:#666;font-size:12px;">当前安装根目录: <code>' .. current_install_root .. '</code>,实际运行目录: <code>' .. current_oc_root .. '</code></div>'
-- 版本选择对话框 (默认隐藏)
html[#html+1] = '<div id="oc-setup-dialog" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:10000;align-items:center;justify-content:center;">'
@@ -59,6 +66,12 @@ act.cfgvalue = function(self, section)
html[#html+1] = '<div><strong style="color:#333;">🆕 最新版</strong>'
html[#html+1] = '<div style="font-size:12px;color:#e36209;margin-top:4px;">⚠️ 安装 npm 上的最新发布版本,可能存在未经验证的兼容性问题。</div>'
html[#html+1] = '</div></label>'
html[#html+1] = '<div style="padding:14px 16px;border:1px solid #d8dee4;border-radius:8px;background:#fafbfc;">'
html[#html+1] = '<label for="oc-install-root" style="display:block;font-weight:600;color:#333;margin-bottom:6px;">📁 安装根目录 / 检测目录</label>'
html[#html+1] = '<input id="oc-install-root" type="text" value="' .. current_install_root .. '" style="width:100%;box-sizing:border-box;padding:8px 10px;border:1px solid #c9d1d9;border-radius:6px;" placeholder="/mnt/emmc">'
html[#html+1] = '<div style="font-size:12px;color:#666;margin-top:6px;line-height:1.6;">填写已挂载的存储路径,例如 <code>/mnt/emmc</code>。OpenClaw 会自动使用 <code>&lt;目录&gt;/openclaw/</code> 作为实际安装目录。更换已安装环境的目录不做迁移,如需切换请先卸载后重装。</div>'
html[#html+1] = '<div id="oc-install-root-preview" style="font-size:12px;color:#0969da;margin-top:6px;">实际安装目录: <code>' .. current_oc_root .. '</code></div>'
html[#html+1] = '</div>'
html[#html+1] = '</div>'
-- 按钮区
html[#html+1] = '<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:20px;">'
@@ -82,9 +95,15 @@ act.cfgvalue = function(self, section)
-- 版本选择对话框逻辑
html[#html+1] = 'var _setupTimer=null;'
html[#html+1] = 'var _ocLastInstallRoot=' .. string.format("%q", current_install_root) .. ';'
html[#html+1] = 'var _ocLastInstallPath=' .. string.format("%q", current_oc_root) .. ';'
html[#html+1] = 'function ocNormalizeInstallRoot(v){v=(v||"").replace(/^\\s+|\\s+$/g,"");if(!v)return"/opt";if(v.charAt(0)!=="/")return null;if(/\\s/.test(v))return null;v=v.replace(/\\/+$/,"");return v||"/";}'
html[#html+1] = 'function ocGetActualInstallPath(root){return root==="/"?"/openclaw":root+"/openclaw";}'
html[#html+1] = 'function ocRefreshInstallRootPreview(){var input=document.getElementById("oc-install-root");var preview=document.getElementById("oc-install-root-preview");if(!input||!preview)return;var root=ocNormalizeInstallRoot(input.value);if(!root){preview.innerHTML="<span style=\\"color:#cf222e;\\">请输入绝对路径,且不要包含空格,例如 <code>/mnt/emmc</code></span>";return;}preview.innerHTML="实际安装目录: <code>"+ocGetActualInstallPath(root)+"</code>";}'
html[#html+1] = 'function ocShowSetupDialog(){'
html[#html+1] = 'var dlg=document.getElementById("oc-setup-dialog");'
html[#html+1] = 'dlg.style.display="flex";'
html[#html+1] = 'ocRefreshInstallRootPreview();'
html[#html+1] = 'var radios=document.getElementsByName("oc-ver-choice");'
html[#html+1] = 'for(var i=0;i<radios.length;i++){if(radios[i].value==="stable")radios[i].checked=true;}'
html[#html+1] = '}'
@@ -93,8 +112,13 @@ act.cfgvalue = function(self, section)
html[#html+1] = '}'
html[#html+1] = 'function ocConfirmSetup(){'
html[#html+1] = 'var btn=document.getElementById("btn-setup");'
html[#html+1] = 'var installRootInput=document.getElementById("oc-install-root");'
html[#html+1] = 'var installRoot=ocNormalizeInstallRoot(installRootInput?installRootInput.value:"");'
html[#html+1] = 'if(!installRoot){alert("请输入绝对路径,且不要包含空格,例如 /mnt/emmc");return;}'
html[#html+1] = '_ocLastInstallRoot=installRoot;'
html[#html+1] = '_ocLastInstallPath=ocGetActualInstallPath(installRoot);'
html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 检测系统配置...";'
html[#html+1] = '(new XHR()).get("' .. check_system_url .. '",null,function(x){'
html[#html+1] = '(new XHR()).get("' .. check_system_url .. '?install_root="+encodeURIComponent(installRoot),null,function(x){'
html[#html+1] = 'try{'
html[#html+1] = 'var r=JSON.parse(x.responseText);'
html[#html+1] = 'var panel=document.getElementById("setup-log-panel");'
@@ -111,19 +135,21 @@ act.cfgvalue = function(self, section)
html[#html+1] = 'logEl.textContent+="════════════════════════════════════════\\n";'
html[#html+1] = 'logEl.textContent+="🔍 系统配置检测\\n";'
html[#html+1] = 'logEl.textContent+="════════════════════════════════════════\\n";'
html[#html+1] = 'logEl.textContent+="检测目录: "+(r.install_root||installRoot)+"\\n";'
html[#html+1] = 'logEl.textContent+="实际安装: "+(r.oc_root||_ocLastInstallPath)+"\\n";'
html[#html+1] = 'logEl.textContent+="内存: "+r.memory_mb+" MB (需要 ≥ 1024 MB) — "+(r.memory_ok?"✅ 通过":"❌ 不达标")+"\\n";'
html[#html+1] = 'logEl.textContent+="磁盘: "+r.disk_mb+" MB 可用 (需要 ≥ 1536 MB) — "+(r.disk_ok?"✅ 通过":"❌ 不达标")+"\\n";'
html[#html+1] = 'logEl.textContent+="磁盘: "+r.disk_mb+" MB 可用"+(r.disk_path?" [检测路径 "+r.disk_path+"]":"")+" (需要 ≥ 1536 MB) — "+(r.disk_ok?"✅ 通过":"❌ 不达标")+"\\n";'
html[#html+1] = 'logEl.textContent+="\\n";'
html[#html+1] = 'if(!r.pass){'
html[#html+1] = 'ocCloseSetupDialog();'
html[#html+1] = 'btn.disabled=false;btn.textContent="📦 安装运行环境";'
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#cf222e;\\">❌ 系统配置不满足要求</span>";'
html[#html+1] = 'logEl.textContent+="❌ 系统配置不满足要求,安装已终止\\n";'
html[#html+1] = 'logEl.textContent+="💡 请升级硬件配置或清理磁盘空间后重试\\n";'
html[#html+1] = 'if(r.message)logEl.textContent+="💡 "+r.message+"\\n";'
html[#html+1] = 'resultEl.style.display="block";'
html[#html+1] = 'resultEl.innerHTML="<div style=\\"border:1px solid #f5c6cb;background:#ffeef0;padding:12px 16px;border-radius:6px;\\">"+'
html[#html+1] = '"<strong style=\\"color:#cf222e;font-size:14px;\\">❌ 系统配置不满足要求</strong><br/>"+'
html[#html+1] = '"<div style=\\"margin-top:8px;font-size:12px;color:#666;\\">💡 请升级硬件配置或清理磁盘空间后重试。</div></div>";'
html[#html+1] = '"<div style=\\"margin-top:8px;font-size:12px;color:#666;\\">💡 "+(r.message||"请升级硬件配置或清理磁盘空间后重试。")+"</div></div>";'
html[#html+1] = 'return;'
html[#html+1] = '}'
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#7aa2f7;\\">⏳ 安装进行中...</span>";'
@@ -133,7 +159,7 @@ act.cfgvalue = function(self, section)
html[#html+1] = 'var choice="stable";'
html[#html+1] = 'for(var i=0;i<radios.length;i++){if(radios[i].checked){choice=radios[i].value;break;}}'
html[#html+1] = 'var verParam=(choice==="stable")?"stable":"latest";'
html[#html+1] = 'ocSetup(verParam,r.memory_mb,r.disk_mb);'
html[#html+1] = 'ocSetup(verParam,installRoot);'
html[#html+1] = '}catch(e){'
html[#html+1] = 'ocCloseSetupDialog();'
html[#html+1] = 'btn.disabled=false;btn.textContent="📦 安装运行环境";'
@@ -142,16 +168,18 @@ act.cfgvalue = function(self, section)
html[#html+1] = '}'
-- 安装运行环境 (带实时日志)
html[#html+1] = 'function ocSetup(version,mem_mb,disk_mb){'
html[#html+1] = 'function ocSetup(version,installRoot){'
html[#html+1] = 'var btn=document.getElementById("btn-setup");'
html[#html+1] = 'var logEl=document.getElementById("setup-log-content");'
html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 安装中...";'
html[#html+1] = 'logEl.textContent+="════════════════════════════════════════\\n";'
html[#html+1] = 'logEl.textContent+="📦 安装运行环境 ("+((version==="stable")?"稳定版":"最新版")+")\\n";'
html[#html+1] = 'logEl.textContent+="安装根目录: "+installRoot+"\\n";'
html[#html+1] = 'logEl.textContent+="实际安装目录: "+ocGetActualInstallPath(installRoot)+"\\n";'
html[#html+1] = 'logEl.textContent+="════════════════════════════════════════\\n";'
html[#html+1] = 'logEl.textContent+="正在启动安装...\\n";'
html[#html+1] = '(new XHR()).get("' .. ctl_url .. '?action=setup&version="+encodeURIComponent(version),null,function(x){'
html[#html+1] = 'try{JSON.parse(x.responseText);}catch(e){}'
html[#html+1] = '(new XHR()).get("' .. ctl_url .. '?action=setup&version="+encodeURIComponent(version)+"&install_root="+encodeURIComponent(installRoot),null,function(x){'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);if(r.status&&r.status!=="ok"){logEl.textContent+="❌ "+(r.message||"安装启动失败")+"\\n";ocSetupDone(false,logEl.textContent);return;}}catch(e){}'
html[#html+1] = 'ocPollSetupLog();'
html[#html+1] = '});'
html[#html+1] = '}'
@@ -217,6 +245,12 @@ act.cfgvalue = function(self, section)
html[#html+1] = 'var reasons=[];'
html[#html+1] = 'if(!log)return"未知错误,请检查日志。";'
html[#html+1] = 'var ll=log.toLowerCase();'
html[#html+1] = 'var ocRoot=_ocLastInstallPath||' .. string.format("%q", current_oc_root) .. ';'
html[#html+1] = 'var nodeBase=ocRoot+"/node";'
html[#html+1] = 'var globalBase=ocRoot+"/global";'
html[#html+1] = 'if(log.indexOf("当前已安装在")>=0||log.indexOf("检测目录不存在")>=0||log.indexOf("安装根目录")>=0){'
html[#html+1] = 'reasons.push("📁 <b>安装目录配置有误</b> — 请选择已挂载的绝对路径;如果当前环境已经安装在其他目录,请先卸载后再切换。");'
html[#html+1] = '}'
-- 网络问题
html[#html+1] = 'if(ll.indexOf("could not resolve")>=0||ll.indexOf("connection timed out")>=0||ll.indexOf("curl")>=0&&ll.indexOf("fail")>=0||ll.indexOf("wget")>=0&&ll.indexOf("fail")>=0||ll.indexOf("所有镜像均下载失败")>=0){'
html[#html+1] = 'reasons.push("🌐 <b>网络连接失败</b> — 无法下载 Node.js。请检查路由器是否能访问外网。<br/>&nbsp;&nbsp;💡 解决: 检查 DNS 设置和网络连接,或手动指定镜像: <code>NODE_MIRROR=https://npmmirror.com/mirrors/node openclaw-env setup</code>");'
@@ -231,15 +265,15 @@ act.cfgvalue = function(self, section)
html[#html+1] = '}'
-- npm 安装失败
html[#html+1] = 'if(ll.indexOf("npm err")>=0||ll.indexOf("npm warn")>=0&&ll.indexOf("openclaw 安装验证失败")>=0){'
html[#html+1] = 'reasons.push("📦 <b>npm 安装 OpenClaw 失败</b> — npm 包下载或安装出错。<br/>&nbsp;&nbsp;💡 解决: 尝试手动安装 <code>PATH=/opt/openclaw/node/bin:$PATH npm install -g openclaw@latest --prefix=/opt/openclaw/global</code>");'
html[#html+1] = 'reasons.push("📦 <b>npm 安装 OpenClaw 失败</b> — npm 包下载或安装出错。<br/>&nbsp;&nbsp;💡 解决: 尝试手动安装 <code>PATH="+nodeBase+"/bin:$PATH npm install -g openclaw@latest --prefix="+globalBase+"</code>");'
html[#html+1] = '}'
-- 权限问题
html[#html+1] = 'if(ll.indexOf("permission denied")>=0||ll.indexOf("eacces")>=0){'
html[#html+1] = 'reasons.push("🔒 <b>权限不足</b> — 文件或目录权限问题。<br/>&nbsp;&nbsp;💡 解决: 运行 <code>chown -R openclaw:openclaw /opt/openclaw</code> 或以 root 用户重试。");'
html[#html+1] = 'reasons.push("🔒 <b>权限不足</b> — 文件或目录权限问题。<br/>&nbsp;&nbsp;💡 解决: 运行 <code>chown -R openclaw:openclaw "+ocRoot+"</code> 或以 root 用户重试。");'
html[#html+1] = '}'
-- tar 解压失败
html[#html+1] = 'if(ll.indexOf("tar")>=0&&(ll.indexOf("error")>=0||ll.indexOf("fail")>=0)){'
html[#html+1] = 'reasons.push("📂 <b>解压失败</b> — Node.js 安装包可能下载不完整。<br/>&nbsp;&nbsp;💡 解决: 删除缓存重试 <code>rm -rf /opt/openclaw/node && openclaw-env setup</code>");'
html[#html+1] = 'reasons.push("📂 <b>解压失败</b> — Node.js 安装包可能下载不完整。<br/>&nbsp;&nbsp;💡 解决: 删除缓存重试 <code>rm -rf "+nodeBase+" && openclaw-env setup</code>");'
html[#html+1] = '}'
-- 验证失败
html[#html+1] = 'if(ll.indexOf("安装验证失败")>=0){'
@@ -251,6 +285,7 @@ act.cfgvalue = function(self, section)
html[#html+1] = '}'
html[#html+1] = 'return reasons.join("<br/><br/>");'
html[#html+1] = '}'
html[#html+1] = 'var _ocInstallRootInput=document.getElementById("oc-install-root");if(_ocInstallRootInput){_ocInstallRootInput.oninput=ocRefreshInstallRootPreview;_ocInstallRootInput.onchange=ocRefreshInstallRootPreview;}'
-- 普通服务操作 (restart/stop)
html[#html+1] = 'function ocServiceCtl(action){'
@@ -384,7 +419,7 @@ act.cfgvalue = function(self, section)
-- 卸载运行环境
html[#html+1] = 'function ocUninstall(){'
html[#html+1] = 'if(!confirm("确定要卸载 OpenClaw 运行环境?\\n\\n将删除 Node.js、OpenClaw 程序及配置数据(/opt/openclaw 目录),服务将停止运行。\\n\\n插件本身不会被删除之后可重新安装运行环境。"))return;'
html[#html+1] = 'if(!confirm("确定要卸载 OpenClaw 运行环境?\\n\\n将删除 Node.js、OpenClaw 程序及配置数据(' .. current_oc_root .. ' 目录),服务将停止运行。\\n\\n插件本身不会被删除之后可重新安装运行环境。"))return;'
html[#html+1] = 'var btn=document.getElementById("btn-uninstall");'
html[#html+1] = 'var el=document.getElementById("action-result");'
html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 正在卸载...";'

35
luasrc/openclaw/paths.lua Normal file
View File

@@ -0,0 +1,35 @@
local M = {}
local DEFAULT_INSTALL_ROOT = "/opt"
function M.normalize_install_root(path)
if type(path) ~= "string" or path == "" then
return DEFAULT_INSTALL_ROOT
end
if path:sub(1, 1) ~= "/" then
return DEFAULT_INSTALL_ROOT
end
path = path:gsub("/+$", "")
if path == "" then
path = "/"
end
return path
end
function M.derive_paths(path)
local install_root = M.normalize_install_root(path)
local oc_root = (install_root == "/") and "/openclaw" or (install_root .. "/openclaw")
return {
install_root = install_root,
oc_root = oc_root,
node_base = oc_root .. "/node",
oc_global = oc_root .. "/global",
oc_data = oc_root .. "/data",
}
end
return M

View File

@@ -1,5 +1,6 @@
config openclaw 'main'
option enabled '0'
option install_root '/opt'
option port '18789'
option bind 'lan'
option token ''

View File

@@ -10,15 +10,15 @@ EXTRA_HELP=" setup 下载 Node.js 并安装 OpenClaw
status_service 显示服务状态
restart_gateway 仅重启 Gateway 实例 (不影响 Web PTY)"
NODE_BASE="/opt/openclaw/node"
OC_GLOBAL="/opt/openclaw/global"
OC_DATA="/opt/openclaw/data"
. /usr/libexec/openclaw-paths.sh
oc_load_paths "$OPENCLAW_INSTALL_ROOT"
# ── OverlayFS 兼容性修复 ──
# Docker bind mount (/overlay/upper/opt/docker) 会导致 /opt 不可写
# 解决: bind mount upper 层的 /opt 到合并视图的 /opt
_oc_fix_opt() {
mkdir -p /opt/openclaw/.probe 2>/dev/null && { rmdir /opt/openclaw/.probe 2>/dev/null; return 0; }
oc_install_root_uses_opt_workaround "$OPENCLAW_INSTALL_ROOT" || return 0
mkdir -p "${OC_ROOT}/.probe" 2>/dev/null && { rmdir "${OC_ROOT}/.probe" 2>/dev/null; return 0; }
if [ -d /overlay/upper/opt ]; then
mkdir -p /overlay/upper/opt/openclaw 2>/dev/null
mount --bind /overlay/upper/opt /opt 2>/dev/null && return 0
@@ -306,6 +306,7 @@ HOME="$OC_DATA" \
OPENCLAW_HOME="$OC_DATA" \
OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw" \
OPENCLAW_CONFIG_PATH="$CONFIG_FILE" \
OPENCLAW_INSTALL_ROOT="$OPENCLAW_INSTALL_ROOT" \
NODE_ICU_DATA="${NODE_BASE}/share/icu" \
NODE_BASE="$NODE_BASE" \
OC_GLOBAL="$OC_GLOBAL" \
@@ -335,6 +336,7 @@ procd_set_param env \
OC_CONFIG_PORT="$pty_port" \
OC_PTY_TOKEN="$pty_token" \
OC_CONFIG_SCRIPT="/usr/share/openclaw/oc-config.sh" \
OPENCLAW_INSTALL_ROOT="$OPENCLAW_INSTALL_ROOT" \
NODE_ICU_DATA="${NODE_BASE}/share/icu" \
NODE_BASE="$NODE_BASE" \
OC_GLOBAL="$OC_GLOBAL" \

View File

@@ -2,12 +2,11 @@
# ============================================================================
# luci-app-openclaw — 全局环境变量
# 仅在 Node.js 已安装时生效,为 SSH 登录用户提供正确的运行环境
# 解决 Issue #42: 统一配置文件路径,避免 /root/.openclaw 与 /opt/openclaw/data/.openclaw 混乱
# 解决 Issue #42: 统一配置文件路径,避免 /root/.openclaw 与运行目录混乱
# ============================================================================
NODE_BASE="/opt/openclaw/node"
OC_GLOBAL="/opt/openclaw/global"
OC_DATA="/opt/openclaw/data"
. /usr/libexec/openclaw-paths.sh
oc_load_paths "$OPENCLAW_INSTALL_ROOT"
# 检查 Node.js 是否已安装
[ -x "${NODE_BASE}/bin/node" ] || return 0

View File

@@ -1,11 +1,20 @@
#!/bin/sh
# luci-app-openclaw — 首次安装/升级初始化脚本
CURRENT_INSTALL_ROOT=$(uci -q get openclaw.main.install_root)
if [ -z "$CURRENT_INSTALL_ROOT" ]; then
uci set openclaw.main.install_root='/opt'
uci commit openclaw
CURRENT_INSTALL_ROOT='/opt'
fi
. /usr/libexec/openclaw-paths.sh
oc_load_paths "$CURRENT_INSTALL_ROOT"
# ── v1.0.16: 清理错误路径下的配置文件 (Issue #42) ──
# 用户在 SSH 中直接运行 openclaw 命令时,可能创建了 /root/.openclaw/ 目录
# 需要迁移数据并清理,避免路径混乱
if [ -d "/root/.openclaw" ]; then
OC_DATA="/opt/openclaw/data"
# 迁移 skills 目录 (如果存在且目标不存在)
if [ -d "/root/.openclaw/skills" ] && [ ! -d "${OC_DATA}/.openclaw/skills" ]; then
mkdir -p "${OC_DATA}/.openclaw"
@@ -41,7 +50,7 @@ if ! id openclaw >/dev/null 2>&1; then
done
# OpenWrt 方式:直接写入 /etc/passwd 和 /etc/shadow
if ! grep -q '^openclaw:' /etc/passwd 2>/dev/null; then
echo "openclaw:x:${OC_UID}:${OC_GID}:openclaw:/opt/openclaw/data:/bin/false" >> /etc/passwd
echo "openclaw:x:${OC_UID}:${OC_GID}:openclaw:${OC_DATA}:/bin/false" >> /etc/passwd
fi
if ! grep -q '^openclaw:' /etc/shadow 2>/dev/null; then
echo 'openclaw:x:0:0:99999:7:::' >> /etc/shadow
@@ -53,19 +62,21 @@ fi
# 创建数据目录
# ── OverlayFS 兼容: Docker bind mount 可能导致 /opt 不可写 ──
if ! mkdir -p /opt/openclaw/.probe 2>/dev/null; then
if oc_install_root_uses_opt_workaround "$OPENCLAW_INSTALL_ROOT"; then
if ! mkdir -p "${OC_ROOT}/.probe" 2>/dev/null; then
if [ -d /overlay/upper/opt ]; then
mkdir -p /overlay/upper/opt/openclaw 2>/dev/null
mount --bind /overlay/upper/opt /opt 2>/dev/null
fi
rmdir /opt/openclaw/.probe 2>/dev/null
rmdir "${OC_ROOT}/.probe" 2>/dev/null
else
rmdir /opt/openclaw/.probe 2>/dev/null
rmdir "${OC_ROOT}/.probe" 2>/dev/null
fi
mkdir -p /opt/openclaw/data/.openclaw
mkdir -p /opt/openclaw/node
mkdir -p /opt/openclaw/global
chown -R openclaw:openclaw /opt/openclaw 2>/dev/null || true
fi
mkdir -p "${OC_DATA}/.openclaw"
mkdir -p "$NODE_BASE"
mkdir -p "$OC_GLOBAL"
chown -R openclaw:openclaw "$OC_ROOT" 2>/dev/null || true
# 生成随机 Token (如果尚未设置)
CURRENT_TOKEN=$(uci -q get openclaw.main.token)

View File

@@ -11,6 +11,8 @@
# ============================================================================
set -e
. /usr/libexec/openclaw-paths.sh
# ── Node.js 版本策略 (双版本兼容) ──
# V2: 当前推荐版本,用于 OpenClaw v2026.3.11+ (要求 >= 22.16.0)
# V1: 旧版兼容,用于 OpenClaw v2026.3.8 及更早版本
@@ -22,18 +24,17 @@ NODE_VERSION="${NODE_VERSION:-${NODE_VERSION_V2}}"
OC_TESTED_VERSION="2026.3.13"
# 用户可通过 OC_VERSION 环境变量覆盖安装版本
OC_VERSION="${OC_VERSION:-}"
NODE_BASE="/opt/openclaw/node"
OC_GLOBAL="/opt/openclaw/global"
OC_DATA="/opt/openclaw/data"
oc_load_paths "$OPENCLAW_INSTALL_ROOT"
# ── OverlayFS 兼容性修复 ──
# iStoreOS/OpenWrt 上 Docker 的 bind mount (/overlay/upper/opt/docker)
# 会导致 OverlayFS 合并视图中 /opt 完全不可写 (mkdir 报 "Directory not empty")。
# 解决方案: 将 /overlay/upper/opt bind mount 到 /opt绕过 OverlayFS 冲突。
_oc_fix_opt() {
oc_install_root_uses_opt_workaround "$OPENCLAW_INSTALL_ROOT" || return 0
# 如果 /opt 可正常写入,无需修复
if mkdir -p /opt/openclaw/.probe 2>/dev/null; then
rmdir /opt/openclaw/.probe 2>/dev/null
if mkdir -p "${OC_ROOT}/.probe" 2>/dev/null; then
rmdir "${OC_ROOT}/.probe" 2>/dev/null
return 0
fi
# /opt 不可写且 overlay upper 层存在 — 执行 bind mount 修复
@@ -72,7 +73,9 @@ ensure_mkdir() {
[ -d "$target" ] && return 0
if ! mkdir -p "$target" 2>/dev/null; then
log_error "无法创建目录: $target"
if oc_install_root_uses_opt_workaround "$OPENCLAW_INSTALL_ROOT"; then
log_error "如果安装了 Docker可能需要手动执行: mount --bind /overlay/upper/opt /opt"
fi
return 1
fi
}
@@ -495,7 +498,7 @@ do_check() {
# 磁盘使用
local used
used=$(du -sh /opt/openclaw 2>/dev/null | awk '{print $1}')
used=$(du -sh "$OC_ROOT" 2>/dev/null | awk '{print $1}')
echo " 磁盘: ${used:-N/A}"
# libc 类型

View File

@@ -0,0 +1,78 @@
#!/bin/sh
# Shared OpenClaw install-root and derived-path helpers.
OPENCLAW_DEFAULT_INSTALL_ROOT="${OPENCLAW_DEFAULT_INSTALL_ROOT:-/opt}"
oc_normalize_install_root() {
local path="$1"
if [ -z "$path" ]; then
path="$OPENCLAW_DEFAULT_INSTALL_ROOT"
fi
case "$path" in
/*) ;;
*) path="$OPENCLAW_DEFAULT_INSTALL_ROOT" ;;
esac
while [ "$path" != "/" ] && [ "${path%/}" != "$path" ]; do
path="${path%/}"
done
[ -n "$path" ] || path="/"
printf '%s\n' "$path"
}
oc_read_install_root_from_uci() {
if command -v uci >/dev/null 2>&1; then
uci -q get openclaw.main.install_root 2>/dev/null
fi
}
oc_load_paths() {
local requested_root="$1"
local install_root="$requested_root"
[ -n "$install_root" ] || install_root="${OPENCLAW_INSTALL_ROOT:-}"
[ -n "$install_root" ] || install_root="$(oc_read_install_root_from_uci)"
OPENCLAW_INSTALL_ROOT="$(oc_normalize_install_root "$install_root")"
if [ "$OPENCLAW_INSTALL_ROOT" = "/" ]; then
OC_ROOT="/openclaw"
else
OC_ROOT="${OPENCLAW_INSTALL_ROOT}/openclaw"
fi
NODE_BASE="${OC_ROOT}/node"
OC_GLOBAL="${OC_ROOT}/global"
OC_DATA="${OC_ROOT}/data"
export OPENCLAW_INSTALL_ROOT OC_ROOT NODE_BASE OC_GLOBAL OC_DATA
}
oc_find_existing_path() {
local path
path="$(oc_normalize_install_root "$1")"
while [ "$path" != "/" ] && [ ! -e "$path" ]; do
path="${path%/*}"
[ -n "$path" ] || path="/"
done
printf '%s\n' "$path"
}
oc_install_root_uses_opt_workaround() {
[ "$(oc_normalize_install_root "${1:-$OPENCLAW_INSTALL_ROOT}")" = "/opt" ]
}
oc_print_env() {
oc_load_paths "$1"
cat <<EOF
OPENCLAW_INSTALL_ROOT=$OPENCLAW_INSTALL_ROOT
OC_ROOT=$OC_ROOT
NODE_BASE=$NODE_BASE
OC_GLOBAL=$OC_GLOBAL
OC_DATA=$OC_DATA
EOF
}

View File

@@ -29,9 +29,8 @@ get_pid_by_port() {
}
# ── 路径 (OpenWrt 适配) ──
NODE_BASE="${NODE_BASE:-/opt/openclaw/node}"
OC_GLOBAL="${OC_GLOBAL:-/opt/openclaw/global}"
OC_DATA="${OC_DATA:-/opt/openclaw/data}"
. /usr/libexec/openclaw-paths.sh
oc_load_paths "$OPENCLAW_INSTALL_ROOT"
NODE_BIN="${NODE_BASE}/bin/node"
OC_STATE_DIR="${OC_DATA}/.openclaw"
CONFIG_FILE="${OC_STATE_DIR}/openclaw.json"
@@ -99,7 +98,7 @@ json_set() {
local parent_dir="$(dirname "$CONFIG_FILE")"
if ! mkdir -p "$parent_dir" 2>/dev/null; then
echo "ERROR: 无法创建配置目录 $parent_dir" >&2
echo "HINT: 请检查 /opt/openclaw/data 是否存在且有写权限" >&2
echo "HINT: 请检查 ${OC_DATA} 是否存在且有写权限" >&2
return 1
fi
@@ -2362,7 +2361,7 @@ backup_restore_menu() {
# 提取 payload 到根目录 (还原到原始绝对路径)
tar -xzf "$latest" --strip-components=3 -C / "${backup_name}/payload/posix/" 2>&1
# 修复权限
chown -R openclaw:openclaw /opt/openclaw/data/.openclaw 2>/dev/null
chown -R openclaw:openclaw "${OC_DATA}/.openclaw" 2>/dev/null
echo -e " ${GREEN}✅ 配置和数据已完整恢复!原配置已保存为 openclaw.json.pre-restore${NC}"
echo ""
prompt_with_default "是否重启服务使配置生效? (Y/n)" "Y" do_restart

View File

@@ -14,12 +14,36 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
function loadInstallRoot() {
if (process.env.OPENCLAW_INSTALL_ROOT) {
return normalizeInstallRoot(process.env.OPENCLAW_INSTALL_ROOT);
}
try {
const { execSync } = require('child_process');
return normalizeInstallRoot(execSync('uci -q get openclaw.main.install_root 2>/dev/null', {
encoding: 'utf8',
timeout: 3000,
}).trim());
} catch {
return '/opt';
}
}
function normalizeInstallRoot(value) {
const cleaned = (value || '').trim();
if (!cleaned || cleaned[0] !== '/' || /\s/.test(cleaned)) return '/opt';
const normalized = cleaned.replace(/\/+$/, '');
return normalized || '/';
}
// ── 配置 (OpenWrt 适配) ──
const PORT = parseInt(process.env.OC_CONFIG_PORT || '18793', 10);
const HOST = process.env.OC_CONFIG_HOST || '0.0.0.0'; // token 认证保护,可安全绑定所有接口
const NODE_BASE = process.env.NODE_BASE || '/opt/openclaw/node';
const OC_GLOBAL = process.env.OC_GLOBAL || '/opt/openclaw/global';
const OC_DATA = process.env.OC_DATA || '/opt/openclaw/data';
const INSTALL_ROOT = loadInstallRoot();
const OC_ROOT = INSTALL_ROOT === '/' ? '/openclaw' : `${INSTALL_ROOT}/openclaw`;
const NODE_BASE = process.env.NODE_BASE || `${OC_ROOT}/node`;
const OC_GLOBAL = process.env.OC_GLOBAL || `${OC_ROOT}/global`;
const OC_DATA = process.env.OC_DATA || `${OC_ROOT}/data`;
const SCRIPT_PATH = process.env.OC_CONFIG_SCRIPT || '/usr/share/openclaw/oc-config.sh';
const SSL_CERT = '/etc/uhttpd.crt';
const SSL_KEY = '/etc/uhttpd.key';
@@ -170,6 +194,7 @@ class PtySession {
const env = {
...process.env, TERM: 'xterm-256color', COLUMNS: String(this.cols), LINES: String(this.rows),
COLORTERM: 'truecolor', LANG: 'en_US.UTF-8',
OPENCLAW_INSTALL_ROOT: INSTALL_ROOT,
NODE_BASE, OC_GLOBAL, OC_DATA,
HOME: OC_DATA,
OPENCLAW_HOME: OC_DATA,

View File

@@ -0,0 +1,37 @@
local script_dir = arg[0]:match("^(.*)/[^/]+$")
local repo_root = script_dir:gsub("/tests$", "")
package.path = table.concat({
repo_root .. "/luasrc/?.lua",
repo_root .. "/luasrc/?/init.lua",
repo_root .. "/luasrc/?/?.lua",
package.path,
}, ";")
local paths = require("openclaw.paths")
local function assert_eq(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %q, got %q", label, expected, actual), 2)
end
end
local function check_root(input, expected_root, expected_base)
local normalized = paths.normalize_install_root(input)
local derived = paths.derive_paths(input)
assert_eq(normalized, expected_root, "normalized root")
assert_eq(derived.install_root, expected_root, "derived install root")
assert_eq(derived.oc_root, expected_base, "derived OpenClaw root")
assert_eq(derived.node_base, expected_base .. "/node", "node base")
assert_eq(derived.oc_global, expected_base .. "/global", "global base")
assert_eq(derived.oc_data, expected_base .. "/data", "data base")
end
check_root(nil, "/opt", "/opt/openclaw")
check_root("", "/opt", "/opt/openclaw")
check_root("/mnt/emmc/", "/mnt/emmc", "/mnt/emmc/openclaw")
check_root("relative/path", "/opt", "/opt/openclaw")
check_root("/", "/", "/openclaw")
print("ok")

View File

@@ -0,0 +1,30 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
. "$REPO_ROOT/root/usr/libexec/openclaw-paths.sh"
fail() {
echo "FAIL: $1" >&2
exit 1
}
oc_load_paths "/mnt/emmc/"
[ "$OPENCLAW_INSTALL_ROOT" = "/mnt/emmc" ] || fail "normalized install root"
[ "$OC_ROOT" = "/mnt/emmc/openclaw" ] || fail "derived OpenClaw root"
[ "$NODE_BASE" = "/mnt/emmc/openclaw/node" ] || fail "derived node path"
[ "$OC_GLOBAL" = "/mnt/emmc/openclaw/global" ] || fail "derived global path"
[ "$OC_DATA" = "/mnt/emmc/openclaw/data" ] || fail "derived data path"
oc_load_paths "relative/path"
[ "$OPENCLAW_INSTALL_ROOT" = "/opt" ] || fail "fallback install root"
[ "$OC_ROOT" = "/opt/openclaw" ] || fail "fallback OpenClaw root"
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT INT TERM
existing=$(oc_find_existing_path "$tmpdir/missing/nested")
[ "$existing" = "$tmpdir" ] || fail "nearest existing path"
echo "ok"