From 68f24e6658a2ce6a0b794e75601167820eba0bfb Mon Sep 17 00:00:00 2001 From: lingyuzeng Date: Wed, 18 Mar 2026 13:48:07 +0800 Subject: [PATCH] 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 --- Makefile | 4 + README.md | 7 +- .../plans/2026-03-18-custom-install-root.md | 144 ++++++++++ .../2026-03-18-custom-install-root-design.md | 85 ++++++ luasrc/controller/openclaw.lua | 270 +++++++++++++----- luasrc/model/cbi/openclaw/basic.lua | 59 +++- luasrc/openclaw/paths.lua | 35 +++ root/etc/config/openclaw | 1 + root/etc/init.d/openclaw | 10 +- root/etc/profile.d/openclaw.sh | 7 +- root/etc/uci-defaults/99-openclaw | 37 ++- root/usr/bin/openclaw-env | 17 +- root/usr/libexec/openclaw-paths.sh | 78 +++++ root/usr/share/openclaw/oc-config.sh | 9 +- root/usr/share/openclaw/web-pty.js | 31 +- tests/test_openclaw_paths.lua | 37 +++ tests/test_openclaw_paths.sh | 30 ++ 17 files changed, 739 insertions(+), 122 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-18-custom-install-root.md create mode 100644 docs/superpowers/specs/2026-03-18-custom-install-root-design.md create mode 100644 luasrc/openclaw/paths.lua create mode 100644 root/usr/libexec/openclaw-paths.sh create mode 100644 tests/test_openclaw_paths.lua create mode 100644 tests/test_openclaw_paths.sh diff --git a/Makefile b/Makefile index 6cdc6bb..a6275b0 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 7752d06..1bd14a7 100644 --- a/README.md +++ b/README.md @@ -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. 进入「配置管理」可使用向导配置消息渠道 diff --git a/docs/superpowers/plans/2026-03-18-custom-install-root.md b/docs/superpowers/plans/2026-03-18-custom-install-root.md new file mode 100644 index 0000000..f1ed147 --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-custom-install-root.md @@ -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 `/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" +``` diff --git a/docs/superpowers/specs/2026-03-18-custom-install-root-design.md b/docs/superpowers/specs/2026-03-18-custom-install-root-design.md new file mode 100644 index 0000000..1488847 --- /dev/null +++ b/docs/superpowers/specs/2026-03-18-custom-install-root-design.md @@ -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: + +- `/openclaw/node` +- `/openclaw/global` +- `/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 `/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. diff --git a/luasrc/controller/openclaw.lua b/luasrc/controller/openclaw.lua index 4a83e93..40e4c97 100644 --- a/luasrc/controller/openclaw.lua +++ b/luasrc/controller/openclaw.lua @@ -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 -e,init_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)") @@ -597,9 +708,9 @@ function action_backup() end -- 读取 manifest.json 判断备份类型 local backup_type = "unknown" - local manifest_json = sys.exec( - "tar --wildcards -xzf " .. filepath .. " '*/manifest.json' -O 2>/dev/null" - ) + local manifest_json = sys.exec( + "tar --wildcards -xzf " .. shell_quote(filepath) .. " '*/manifest.json' -O 2>/dev/null" + ) if manifest_json and manifest_json ~= "" then -- 简单字符串匹配,避免依赖 JSON 库 if manifest_json:match('"onlyConfig"%s*:%s*true') then @@ -636,17 +747,17 @@ function action_backup() } end - if action == "create" then - 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" - else - backup_cmd = cd_prefix .. "HOME=" .. backup_dir .. " " .. env_prefix .. node_bin .. " " .. oc_entry .. " backup create --no-include-workspace 2>&1" - end + if action == "create" then + local only_config = http.formvalue("only_config") or "1" + local backup_cmd + if only_config == "1" then + 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=" .. 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 - -- fallback 到最新 - restore_path = sys.exec("ls -t " .. backup_dir .. "/*-openclaw-backup.tar.gz 2>/dev/null | head -1"):gsub("%s+", "") + if restore_path == "" or not nixio_fs.stat(restore_path, "type") then + -- fallback 到最新 + 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) -- 备份结构: /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") @@ -739,14 +850,14 @@ function action_backup() -- 5) 提取 payload 文件到根目录 (还原到原始绝对路径) -- 注: --wildcards 与 --strip-components 组合在某些 tar 版本不兼容 -- 使用精确路径前缀代替 wildcards - local extract_cmd = string.format( - "tar -xzf %s --strip-components=%d -C / '%s' 2>&1", - restore_path, strip_count, payload_prefix - ) + local extract_cmd = string.format( + "tar -xzf %s --strip-components=%d -C / '%s' 2>&1", + 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 + result.disk_path = paths.install_root end + result.disk_ok = result.disk_mb >= MIN_DISK_MB + else + result.message = "检测目录不存在: " .. paths.install_root .. "。请先挂载或创建该目录。" end - result.disk_ok = result.disk_mb >= MIN_DISK_MB -- 综合判断 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, ";") diff --git a/luasrc/model/cbi/openclaw/basic.lua b/luasrc/model/cbi/openclaw/basic.lua index cd0b87d..71191ca 100644 --- a/luasrc/model/cbi/openclaw/basic.lua +++ b/luasrc/model/cbi/openclaw/basic.lua @@ -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] = '' html[#html+1] = '
' html[#html+1] = '' + html[#html+1] = '
当前安装根目录: ' .. current_install_root .. ',实际运行目录: ' .. current_oc_root .. '
' -- 版本选择对话框 (默认隐藏) html[#html+1] = '' -- 按钮区 html[#html+1] = '
' @@ -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="请输入绝对路径,且不要包含空格,例如 /mnt/emmc";return;}preview.innerHTML="实际安装目录: "+ocGetActualInstallPath(root)+"";}' 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/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" \ diff --git a/root/etc/profile.d/openclaw.sh b/root/etc/profile.d/openclaw.sh index 969cfcf..bf6e69a 100644 --- a/root/etc/profile.d/openclaw.sh +++ b/root/etc/profile.d/openclaw.sh @@ -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 diff --git a/root/etc/uci-defaults/99-openclaw b/root/etc/uci-defaults/99-openclaw index 6c1bf88..3032961 100755 --- a/root/etc/uci-defaults/99-openclaw +++ b/root/etc/uci-defaults/99-openclaw @@ -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 [ -d /overlay/upper/opt ]; then - mkdir -p /overlay/upper/opt/openclaw 2>/dev/null - mount --bind /overlay/upper/opt /opt 2>/dev/null +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 "${OC_ROOT}/.probe" 2>/dev/null + else + rmdir "${OC_ROOT}/.probe" 2>/dev/null fi - rmdir /opt/openclaw/.probe 2>/dev/null -else - rmdir /opt/openclaw/.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 +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) diff --git a/root/usr/bin/openclaw-env b/root/usr/bin/openclaw-env index 621a092..0189c84 100755 --- a/root/usr/bin/openclaw-env +++ b/root/usr/bin/openclaw-env @@ -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" - log_error "如果安装了 Docker,可能需要手动执行: mount --bind /overlay/upper/opt /opt" + 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 类型 diff --git a/root/usr/libexec/openclaw-paths.sh b/root/usr/libexec/openclaw-paths.sh new file mode 100644 index 0000000..2863b61 --- /dev/null +++ b/root/usr/libexec/openclaw-paths.sh @@ -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 </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 diff --git a/root/usr/share/openclaw/web-pty.js b/root/usr/share/openclaw/web-pty.js index c69bb8b..3eee025 100644 --- a/root/usr/share/openclaw/web-pty.js +++ b/root/usr/share/openclaw/web-pty.js @@ -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, diff --git a/tests/test_openclaw_paths.lua b/tests/test_openclaw_paths.lua new file mode 100644 index 0000000..935f27c --- /dev/null +++ b/tests/test_openclaw_paths.lua @@ -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") diff --git a/tests/test_openclaw_paths.sh b/tests/test_openclaw_paths.sh new file mode 100644 index 0000000..9630f7d --- /dev/null +++ b/tests/test_openclaw_paths.sh @@ -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"