op-packages/luci-app-openclawmgr/root/usr/libexec/istorec/openclawmgr.sh
github-actions[bot] 37afaea9db 🌈 Sync 2026-04-28 04:37:16
2026-04-28 04:37:16 +08:00

1722 lines
51 KiB
Bash
Executable File

#!/bin/sh
set -eu
APP="openclawmgr"
UCI_NS="openclawmgr"
LOCK_DIR="/tmp/openclawmgr-installer.lock"
DIAG_PREFIX="/tmp/openclawmgr-diag"
OPENCLAW_STABLE_NPM_TAG="openclaw@2026.3.28"
OPENCLAW_STABLE_VERSION="2026.3.28"
INSTALL_CHANNEL="${INSTALL_CHANNEL:-}"
log_ts() { date "+%Y-%m-%d %H:%M:%S"; }
uci_get() { uci -q get "${UCI_NS}.main.$1" 2>/dev/null || true; }
validate_base_dir() {
local dir="$1"
dir="$(printf "%s" "$dir" | sed 's/[[:space:]]*$//')"
dir="${dir%/}"
[ -n "$dir" ] || return 1
case "$dir" in
/*) ;;
*) return 1 ;;
esac
[ "$dir" != "/" ] || return 1
case "$dir" in
/root|/root/*) return 1 ;;
esac
case "$dir" in
*"/../"*|*"/.."|*"/./"*|*"/.") return 1 ;;
esac
case "$dir" in
/bin|/bin/*|/sbin|/sbin/*|/lib|/lib/*|/usr|/usr/*|/etc|/etc/*|/proc|/proc/*|/sys|/sys/*|/dev|/dev/*|/run|/run/*|/tmp|/tmp/*|/var|/var/*|/overlay|/overlay/*|/rom|/rom/*)
return 1
;;
esac
return 0
}
uci_get_list() {
local key="$1"
uci -q show "${UCI_NS}.main.${key}" 2>/dev/null | \
sed -n "s/^${UCI_NS}\\.main\\.${key}='\\(.*\\)'$/\\1/p"
}
json_get() {
local file="$1" expr="$2"
[ -f "$file" ] || return 0
command -v jsonfilter >/dev/null 2>&1 || return 0
jsonfilter -i "$file" -e "$expr" 2>/dev/null || true
}
load_gateway_runtime_from_json() {
local cfg="${DATA_DIR}/.openclaw/openclaw.json"
[ -f "$cfg" ] || return 0
local json_port json_bind json_token
json_port="$(json_get "$cfg" '@.gateway.port')"
json_bind="$(json_get "$cfg" '@.gateway.bind')"
json_token="$(json_get "$cfg" '@.gateway.auth.token')"
[ -n "$json_port" ] && PORT="$json_port"
[ -n "$json_bind" ] && BIND="$json_bind"
[ -n "$json_token" ] && TOKEN="$json_token"
}
fmt_elapsed() {
local total="${1:-0}" h m s
case "$total" in ''|*[!0-9]*) total=0 ;; esac
h=$((total / 3600))
m=$(((total % 3600) / 60))
s=$((total % 60))
if [ "$h" -gt 0 ]; then
printf "%dh%dm%ds" "$h" "$m" "$s"
elif [ "$m" -gt 0 ]; then
printf "%dm%ds" "$m" "$s"
else
printf "%ds" "$s"
fi
}
download_with_progress() {
local url="$1" out="$2" err="$3" label="$4"
local start_ts last_change_ts last_size last_report_ts
write_installer_log "$label: $url"
curl -fSL --retry 2 --retry-delay 1 --connect-timeout 15 --max-time 600 \
-o "$out" "$url" 2>"$err" &
local cpid="$!"
start_ts="$(date +%s 2>/dev/null || echo 0)"
last_change_ts="$start_ts"
last_size=0
last_report_ts="$start_ts"
while kill -0 "$cpid" 2>/dev/null; do
sleep 30
kill -0 "$cpid" 2>/dev/null || break
local now_ts size quiet_s elapsed_s elapsed_h size_mb
now_ts="$(date +%s 2>/dev/null || echo 0)"
size="$(wc -c <"$out" 2>/dev/null || echo 0)"
case "$size" in ''|*[!0-9]*) size=0 ;; esac
if [ "$size" -ne "$last_size" ]; then
last_size="$size"
last_change_ts="$now_ts"
fi
if [ $((now_ts - last_report_ts)) -ge 60 ]; then
last_report_ts="$now_ts"
quiet_s=$((now_ts - last_change_ts))
elapsed_s=$((now_ts - start_ts))
elapsed_h="$(fmt_elapsed "$elapsed_s")"
size_mb=$((size / 1048576))
if [ "$size" -gt 0 ]; then
if [ "$quiet_s" -ge 60 ]; then
write_installer_log "$label running (${elapsed_h} elapsed; no new data for ${quiet_s}s; downloaded ${size_mb}MB)"
else
write_installer_log "$label running (${elapsed_h} elapsed; downloaded ${size_mb}MB)"
fi
else
write_installer_log "$label running (${elapsed_h} elapsed; no data yet)"
fi
fi
done
wait "$cpid"
return $?
}
default_allowed_origins() {
local port="$PORT" ip
ip="$(uci -q get network.lan.ipaddr 2>/dev/null || true)"
if [ -z "$ip" ]; then
ip="$(ip -4 -o addr show br-lan 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -n 1)"
fi
if [ -n "$ip" ]; then
printf "http://%s:%s\n" "$ip" "$port"
fi
}
build_allowed_origins_json() {
local origins="${1:-}"
printf "%s" "$origins" | awk '
BEGIN { first=1; print "[" }
{
gsub(/^[ \t]+|[ \t]+$/, "", $0)
if ($0 == "") next
gsub(/\\/,"\\\\",$0); gsub(/"/,"\\\"",$0)
if (first) { first=0; printf " \"%s\"", $0 }
else { printf ",\n \"%s\"", $0 }
}
END {
if (first) { print "]" } else { print "\n ]" }
}'
}
has_default_route() {
ip route 2>/dev/null | grep -q '^default '
}
default_gateway() {
ip route 2>/dev/null | awk '/^default/{print $3; exit}' 2>/dev/null || true
}
ensure_dirs() {
mkdir -p "$BASE_DIR" "$NODE_DIR" "$GLOBAL_DIR" "$DATA_DIR/.openclaw/workspace" "$BASE_DIR/npm-cache" 2>/dev/null || true
printf "%s\n" "managed-by=openclawmgr" >"${BASE_DIR}/.openclawmgr.managed" 2>/dev/null || true
fix_data_permissions || true
}
fix_data_permissions() {
# When running under procd as user "openclawmgr", OpenClaw needs write access to data/.openclaw/*
if ! id openclawmgr >/dev/null 2>&1; then
return 0
fi
chown -R openclawmgr:openclawmgr "$DATA_DIR" 2>/dev/null || true
chmod -R u+rwX "$DATA_DIR" 2>/dev/null || true
}
check_space_mb() {
local path="$1" need_mb="$2"
local avail_kb
avail_kb="$(df -kP "$path" 2>/dev/null | awk 'NR==2{print $4}' 2>/dev/null || echo 0)"
[ -n "$avail_kb" ] || avail_kb=0
if [ "$avail_kb" -lt $((need_mb * 1024)) ] 2>/dev/null; then
write_installer_log "Insufficient disk space at $path: need ${need_mb}MB, available $((avail_kb / 1024))MB"
return 1
fi
return 0
}
proc_ppid() {
local pid="$1"
[ -r "/proc/$pid/stat" ] || return 1
awk '{print $4}' "/proc/$pid/stat" 2>/dev/null
}
pid_in_own_ancestry() {
local target="$1" cur depth
case "$target" in ''|*[!0-9]*) return 1 ;; esac
cur="$$"
depth=0
while [ -n "$cur" ] && [ "$cur" -gt 1 ] 2>/dev/null && [ "$depth" -lt 64 ]; do
[ "$cur" = "$target" ] && return 0
cur="$(proc_ppid "$cur" 2>/dev/null || true)"
depth=$((depth + 1))
done
return 1
}
acquire_lock() {
if mkdir "$LOCK_DIR" 2>/dev/null; then
echo "$$" >"$LOCK_DIR/pid" 2>/dev/null || true
trap 'rm -rf "$LOCK_DIR" 2>/dev/null || true' EXIT
return 0
fi
# Lock exists: check if it's stale (e.g. previous task killed or power loss).
if [ -d "$LOCK_DIR" ]; then
local pid running
pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)"
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
write_installer_log "Another operation is running (lock: $LOCK_DIR, pid: $pid)"
exit 2
fi
running="false"
if [ -x /etc/init.d/tasks ] && command -v jsonfilter >/dev/null 2>&1; then
local st tpid
st="$(/etc/init.d/tasks task_status "$APP" 2>/dev/null || true)"
running="$(printf "%s" "$st" | jsonfilter -e '@.running' 2>/dev/null || echo false)"
tpid="$(printf "%s" "$st" | jsonfilter -e '@.pid' 2>/dev/null || true)"
if [ "$running" = "true" ]; then
if [ -n "${tpid:-}" ] && kill -0 "$tpid" 2>/dev/null; then
if pid_in_own_ancestry "$tpid"; then
write_installer_log "Stale lock detected for current task, recovering: $LOCK_DIR"
running="false"
else
write_installer_log "Another operation is running (taskd: $APP, pid: $tpid)"
exit 2
fi
fi
if [ "$running" = "true" ]; then
# task_status says running, but pid is gone -> stale task record
write_installer_log "Stale task record detected, clearing: $APP"
/etc/init.d/tasks task_del "$APP" >/dev/null 2>&1 || true
running="false"
fi
fi
fi
if [ "$running" = "true" ]; then
write_installer_log "Another operation is running (taskd: $APP)"
exit 2
fi
write_installer_log "Stale lock detected, removing: $LOCK_DIR"
rm -rf "$LOCK_DIR" 2>/dev/null || true
if mkdir "$LOCK_DIR" 2>/dev/null; then
echo "$$" >"$LOCK_DIR/pid" 2>/dev/null || true
trap 'rm -rf "$LOCK_DIR" 2>/dev/null || true' EXIT
return 0
fi
fi
write_installer_log "Another operation is running (lock: $LOCK_DIR)"
exit 2
}
gen_token() {
(hexdump -n 24 -e '24/1 "%02x"' /dev/urandom 2>/dev/null) || \
(openssl rand -hex 24 2>/dev/null) || \
(date +%s | md5sum 2>/dev/null | awk '{print $1}')
}
ensure_token() {
if [ -z "${TOKEN:-}" ]; then
TOKEN="$(gen_token)"
fi
}
is_musl() {
ldd --version 2>&1 | grep -qi musl && return 0
[ -e /lib/ld-musl-*.so.1 ] 2>/dev/null && return 0
return 1
}
detect_arch() {
case "$(uname -m 2>/dev/null || echo unknown)" in
x86_64) echo "linux-x64" ;;
aarch64) echo "linux-arm64" ;;
*) echo "unsupported" ;;
esac
}
ks_admin_port() {
local en ap
en="$(uci -q get istoreenhance.@istoreenhance[0].enabled 2>/dev/null || echo 0)"
[ "$en" = "1" ] || return 1
pidof iStoreEnhance >/dev/null 2>&1 || return 1
ap="$(uci -q get istoreenhance.@istoreenhance[0].adminport 2>/dev/null || echo 5003)"
echo "$ap"
}
download() {
local url="$1" out="$2"
local ap admin_path resp err tmp rc ks_enabled resp_file http_code
tmp="/tmp/${APP}-curl.err"
err="/tmp/${APP}-curl.err2"
resp_file="/tmp/${APP}-ks-remap.json"
rm -f "$tmp" "$err" "$resp_file" 2>/dev/null || true
if [ "${INSTALL_ACCELERATED:-1}" != "1" ]; then
write_installer_log "KSpeeder not used: accelerated install is disabled"
else
ks_enabled="$(uci -q get istoreenhance.@istoreenhance[0].enabled 2>/dev/null || echo 0)"
if [ "$ks_enabled" != "1" ]; then
write_installer_log "KSpeeder not used: istoreenhance is disabled"
elif ! pidof iStoreEnhance >/dev/null 2>&1; then
write_installer_log "KSpeeder not used: iStoreEnhance process is not running"
else
ap="$(uci -q get istoreenhance.@istoreenhance[0].adminport 2>/dev/null || echo 5003)"
http_code="$(curl -sS --connect-timeout 2 --max-time 5 -G \
--data-urlencode "url=${url}" \
-o "$resp_file" -w "%{http_code}" \
"http://127.0.0.1:${ap}/api/domainfold/remap" 2>"$tmp" || echo 000)"
resp="$(cat "$resp_file" 2>/dev/null || true)"
admin_path="$(printf "%s" "$resp" | jsonfilter -e '@.admin_path' 2>/dev/null || true)"
if [ -n "$admin_path" ] && echo "$admin_path" | grep -q '^/'; then
if download_with_progress "http://127.0.0.1:${ap}${admin_path}" "$out" "$err" "Downloading via KSpeeder"; then
return 0
fi
rc=$?
write_installer_log "Download via KSpeeder failed (rc=$rc): ${url}"
tail -n 5 "$err" 2>/dev/null | while IFS= read -r ln; do write_installer_log "$ln"; done
else
local e brief
e="$(printf "%s" "$resp" | jsonfilter -e '@.error' 2>/dev/null || true)"
if [ -n "$e" ]; then
write_installer_log "KSpeeder remap failed (http=${http_code}): $e"
elif [ -n "$http_code" ] && [ "$http_code" != "000" ] && [ "$http_code" != "200" ]; then
brief="$(printf "%s" "$resp" | tr '\n' ' ' | sed 's/[[:space:]]\\+/ /g' | cut -c1-200)"
[ -n "$brief" ] || brief="(empty response)"
write_installer_log "KSpeeder not used: remap http=${http_code}: ${brief}"
elif [ -s "$tmp" ]; then
write_installer_log "KSpeeder not used: remap request failed on 127.0.0.1:${ap}"
tail -n 3 "$tmp" 2>/dev/null | while IFS= read -r ln; do write_installer_log "$ln"; done
else
write_installer_log "KSpeeder not used: remap response missing admin_path"
fi
fi
fi
fi
if ! has_default_route; then
write_installer_log "No default route (default gateway missing); internet is unreachable"
fi
if download_with_progress "$url" "$out" "$err" "Downloading"; then
return 0
fi
rc=$?
write_installer_log "Download failed (rc=$rc): ${url}"
tail -n 5 "$err" 2>/dev/null | while IFS= read -r ln; do write_installer_log "$ln"; done
return "$rc"
}
write_installer_log() {
local msg="$1"
local line
line="$(printf "%s [%s] %s" "$(log_ts)" "$APP" "$msg")"
printf "%s\n" "$line"
}
openclaw_bin() {
local b="${GLOBAL_DIR}/bin/openclaw"
[ -x "$b" ] && { echo "$b"; return 0; }
return 1
}
openclaw_cmd() {
local b
b="$(openclaw_bin 2>/dev/null || true)"
[ -n "$b" ] || return 127
HOME="$DATA_DIR" \
OPENCLAW_HOME="$DATA_DIR" \
OPENCLAW_STATE_DIR="${DATA_DIR}/.openclaw" \
OPENCLAW_CONFIG_PATH="${DATA_DIR}/.openclaw/openclaw.json" \
PATH="${NODE_DIR}/bin:${GLOBAL_DIR}/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
"$b" "$@"
}
find_entry() {
local d="${GLOBAL_DIR}/lib/node_modules/openclaw"
[ -f "${d}/openclaw.mjs" ] && { echo "${d}/openclaw.mjs"; return 0; }
[ -f "${d}/dist/cli.js" ] && { echo "${d}/dist/cli.js"; return 0; }
return 1
}
node_version() {
[ -x "$NODE_BIN" ] || return 0
"$NODE_BIN" --version 2>/dev/null || true
}
openclaw_version() {
local pkg="${GLOBAL_DIR}/lib/node_modules/openclaw/package.json"
[ -f "$pkg" ] || return 0
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$pkg" 2>/dev/null | head -n 1
}
local_openclaw_version() {
[ -x "$NPM_BIN" ] || return 0
HOME="$DATA_DIR" npm_config_cache="${BASE_DIR}/npm-cache" \
PATH="${NODE_DIR}/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
"$NPM_BIN" list -g openclaw --prefix="$GLOBAL_DIR" --depth=0 --json 2>/dev/null \
| "$NODE_BIN" -e 'let s="";process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{try{let o=JSON.parse(s||"{}");let v=o&&o.dependencies&&o.dependencies.openclaw&&o.dependencies.openclaw.version;if(v)process.stdout.write(String(v));}catch(_){}})'
}
latest_openclaw_version() {
local npm_registry=""
if [ "${INSTALL_CHANNEL:-stable}" != "latest" ]; then
printf "%s\n" "$OPENCLAW_STABLE_VERSION"
return 0
fi
[ -x "$NPM_BIN" ] || return 0
if [ "${INSTALL_ACCELERATED:-1}" = "1" ]; then
npm_registry="https://registry.npmmirror.com"
fi
if [ -n "$npm_registry" ]; then
HOME="$DATA_DIR" npm_config_cache="${BASE_DIR}/npm-cache" npm_config_registry="$npm_registry" \
PATH="${NODE_DIR}/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
"$NPM_BIN" view openclaw version 2>/dev/null | tr -d '\r' | head -n 1
else
HOME="$DATA_DIR" npm_config_cache="${BASE_DIR}/npm-cache" \
PATH="${NODE_DIR}/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
"$NPM_BIN" view openclaw version 2>/dev/null | tr -d '\r' | head -n 1
fi
}
target_openclaw_package() {
if [ "${INSTALL_CHANNEL:-stable}" = "latest" ]; then
printf "%s\n" "openclaw@latest"
else
printf "%s\n" "$OPENCLAW_STABLE_NPM_TAG"
fi
}
have_openclaw_runtime() {
[ -x "$NODE_BIN" ] || return 1
find_entry >/dev/null 2>&1 || return 1
return 0
}
agent_key_name() {
case "${DEFAULT_AGENT:-anthropic}" in
openai) echo "OPENAI_API_KEY" ;;
anthropic) echo "ANTHROPIC_API_KEY" ;;
minimax-cn) echo "MINIMAX_API_KEY" ;;
moonshot) echo "MOONSHOT_API_KEY" ;;
custom-provider) echo "" ;;
*) echo "ANTHROPIC_API_KEY" ;;
esac
}
existing_custom_provider_model() {
local cfg="${DATA_DIR}/.openclaw/openclaw.json"
[ -f "$cfg" ] || return 0
OPENCLAW_CONFIG_PATH="$cfg" lua - <<'EOF'
local json = require "luci.jsonc"
local path = os.getenv("OPENCLAW_CONFIG_PATH")
local f = io.open(path, "r")
if not f then
return
end
local raw = f:read("*a")
f:close()
local cfg = json.parse(raw or "") or {}
local primary = cfg.agents and cfg.agents.defaults and cfg.agents.defaults.model and cfg.agents.defaults.model.primary
if type(primary) == "string" and primary:match("^custom%-provider/.+") then
io.write(primary)
return
end
local providers = cfg.models and cfg.models.providers
local custom = providers and providers["custom-provider"]
local models = custom and custom.models
local first = type(models) == "table" and models[1] or nil
local id = first and first.id
if type(id) == "string" and id ~= "" then
io.write("custom-provider/" .. id)
end
EOF
}
agent_default_model() {
case "${DEFAULT_AGENT:-anthropic}" in
openai)
if [ -n "${DEFAULT_MODEL:-}" ]; then
case "$DEFAULT_MODEL" in
openai/*) echo "$DEFAULT_MODEL" ;;
*/*) echo "openai/${DEFAULT_MODEL#*/}" ;;
*) echo "openai/$DEFAULT_MODEL" ;;
esac
else
echo "openai/gpt-5.2"
fi
;;
anthropic)
if [ -n "${DEFAULT_MODEL:-}" ]; then
case "$DEFAULT_MODEL" in
anthropic/*) echo "$DEFAULT_MODEL" ;;
*/*) echo "anthropic/${DEFAULT_MODEL#*/}" ;;
*) echo "anthropic/$DEFAULT_MODEL" ;;
esac
else
echo "anthropic/claude-sonnet-4-6"
fi
;;
minimax-cn)
if [ -n "${DEFAULT_MODEL:-}" ]; then
case "$DEFAULT_MODEL" in
minimax-cn/*) echo "$DEFAULT_MODEL" ;;
*/*) echo "minimax-cn/${DEFAULT_MODEL#*/}" ;;
*) echo "minimax-cn/$DEFAULT_MODEL" ;;
esac
else
echo "minimax-cn/MiniMax-M2.5"
fi
;;
moonshot)
if [ -n "${DEFAULT_MODEL:-}" ]; then
case "$DEFAULT_MODEL" in
moonshot/*) echo "$DEFAULT_MODEL" ;;
*/*) echo "moonshot/${DEFAULT_MODEL#*/}" ;;
*) echo "moonshot/$DEFAULT_MODEL" ;;
esac
else
echo "moonshot/kimi-k2.5"
fi
;;
custom-provider)
if [ -n "${DEFAULT_MODEL:-}" ]; then
case "$DEFAULT_MODEL" in
custom-provider/*) echo "$DEFAULT_MODEL" ;;
*/*) echo "custom-provider/${DEFAULT_MODEL#*/}" ;;
*) echo "custom-provider/$DEFAULT_MODEL" ;;
esac
else
local current_model
current_model="$(existing_custom_provider_model)"
if [ -n "$current_model" ]; then
echo "$current_model"
else
echo "custom-provider/custom-model"
fi
fi
;;
*) echo "anthropic/claude-sonnet-4-6" ;;
esac
}
agent_default_base_url() {
case "${DEFAULT_AGENT:-anthropic}" in
openai) echo "https://api.openai.com/v1" ;;
anthropic) echo "https://api.anthropic.com" ;;
minimax-cn) echo "https://api.minimaxi.com/anthropic" ;;
moonshot) echo "https://api.moonshot.cn/v1" ;;
custom-provider) echo "" ;;
*) echo "https://api.anthropic.com" ;;
esac
}
is_valid_http_url() {
case "${1:-}" in
http://*|https://*) return 0 ;;
*) return 1 ;;
esac
}
ensure_gateway_config() {
local cfg="${DATA_DIR}/.openclaw/openclaw.json"
[ -n "${TOKEN:-}" ] || return 1
if [ -f "$cfg" ]; then
# Only overwrite configs that look managed by OpenClawMgr (backward compatible).
if ! grep -Eq '"controlUi"[[:space:]]*:' "$cfg" 2>/dev/null && \
! grep -Eq '"basePath"[[:space:]]*:[[:space:]]*"\\?/openclawmgr"' "$cfg" 2>/dev/null; then
write_installer_log "Skipping config update because ${cfg} does not look managed by OpenClawMgr"
return 0
fi
fi
local host_header_fallback="false"
if [ "$BIND" != "loopback" ]; then
host_header_fallback="true"
fi
local allow_insecure=""
if [ -n "${ALLOW_INSECURE_AUTH:-}" ]; then
if [ "$ALLOW_INSECURE_AUTH" = "1" ]; then
allow_insecure="true"
else
allow_insecure="false"
fi
fi
local disable_device_auth=""
if [ -n "${DISABLE_DEVICE_AUTH:-}" ]; then
if [ "$DISABLE_DEVICE_AUTH" = "1" ]; then
disable_device_auth="true"
else
disable_device_auth="false"
fi
fi
local allowed_origins="$ALLOWED_ORIGINS"
local selected_key selected_model selected_base_url override_base_url base_url_mode
selected_key=""
selected_model=""
selected_base_url=""
override_base_url=""
base_url_mode=""
if [ -n "${DEFAULT_AGENT:-}" ]; then
selected_key="$(agent_key_name)"
selected_model="$(agent_default_model)"
selected_base_url="$(agent_default_base_url)"
base_url_mode="default"
if [ -n "${PROVIDER_BASE_URL:-}" ]; then
if is_valid_http_url "$PROVIDER_BASE_URL"; then
override_base_url="$PROVIDER_BASE_URL"
base_url_mode="override"
else
write_installer_log "Ignoring invalid relay URL for ${DEFAULT_AGENT}: ${PROVIDER_BASE_URL}"
base_url_mode="preserve"
fi
fi
fi
mkdir -p "$(dirname "$cfg")" 2>/dev/null || true
CFG_PATH="$cfg" \
ALLOWED_ORIGINS_RAW="$allowed_origins" \
DEFAULT_ALLOWED_ORIGINS_RAW="$(default_allowed_origins)" \
GATEWAY_PORT="$PORT" \
GATEWAY_BIND="$BIND" \
GATEWAY_TOKEN="$TOKEN" \
GATEWAY_ALLOW_INSECURE="$allow_insecure" \
GATEWAY_DISABLE_DEVICE_AUTH="$disable_device_auth" \
GATEWAY_HOST_HEADER_FALLBACK="$host_header_fallback" \
DEFAULT_AGENT_NAME="${DEFAULT_AGENT:-}" \
DEFAULT_AGENT_KEY="$selected_key" \
DEFAULT_AGENT_MODEL="$selected_model" \
DEFAULT_AGENT_BASE_URL="$selected_base_url" \
DEFAULT_AGENT_OVERRIDE_BASE_URL="$override_base_url" \
DEFAULT_AGENT_BASE_URL_MODE="$base_url_mode" \
DEFAULT_AGENT_API_KEY="${PROVIDER_API_KEY:-}" \
lua - <<'EOF'
local json = require "luci.jsonc"
local cfg_path = os.getenv("CFG_PATH")
local function slurp(path)
local f = io.open(path, "r")
if not f then return nil end
local data = f:read("*a")
f:close()
return data
end
local function bool_env(name)
return os.getenv(name) == "true"
end
local function optional_env(name)
local value = os.getenv(name)
if value == nil or value == "" then
return nil
end
return value
end
local function optional_bool_env(name)
local value = optional_env(name)
if value == "true" then
return true
end
if value == "false" then
return false
end
return nil
end
local function split_lines(raw)
local out = {}
raw = raw or ""
for line in raw:gmatch("([^\n]+)") do
line = line:gsub("^%s+", ""):gsub("%s+$", "")
if line ~= "" then
table.insert(out, line)
end
end
return out
end
local function ensure_table(parent, key)
if type(parent[key]) ~= "table" then
parent[key] = {}
end
return parent[key]
end
local function is_array(node)
if type(node) ~= "table" then
return false
end
local count = 0
for k, _ in pairs(node) do
if type(k) ~= "number" or k < 1 or k % 1 ~= 0 then
return false
end
count = count + 1
end
for i = 1, count do
if node[i] == nil then
return false
end
end
return true
end
local function prune_empty_tables(node)
if type(node) ~= "table" then
return false
end
if is_array(node) then
local compact = {}
for i = 1, #node do
local value = node[i]
if type(value) == "table" then
if not prune_empty_tables(value) then
compact[#compact + 1] = value
end
else
compact[#compact + 1] = value
end
end
for i = #node, 1, -1 do
node[i] = nil
end
for i = 1, #compact do
node[i] = compact[i]
end
return #node == 0
end
for k, v in pairs(node) do
if type(v) == "table" and prune_empty_tables(v) then
node[k] = nil
end
end
return next(node) == nil
end
local function read_config(path)
local raw = slurp(path)
if not raw or raw == "" then
return {}
end
return json.parse(raw) or {}
end
local cfg = read_config(cfg_path)
local gateway = ensure_table(cfg, "gateway")
gateway.mode = "local"
gateway.port = tonumber(os.getenv("GATEWAY_PORT")) or 18789
gateway.bind = os.getenv("GATEWAY_BIND") or "lan"
local auth = ensure_table(gateway, "auth")
auth.mode = "token"
auth.token = os.getenv("GATEWAY_TOKEN") or ""
local control = ensure_table(gateway, "controlUi")
control.enabled = true
local configured_origins = optional_env("ALLOWED_ORIGINS_RAW")
if configured_origins then
control.allowedOrigins = split_lines(configured_origins)
elseif type(control.allowedOrigins) ~= "table" or next(control.allowedOrigins) == nil then
control.allowedOrigins = split_lines(os.getenv("DEFAULT_ALLOWED_ORIGINS_RAW") or "")
end
local allow_insecure = optional_bool_env("GATEWAY_ALLOW_INSECURE")
if allow_insecure ~= nil then
control.allowInsecureAuth = allow_insecure
elseif type(control.allowInsecureAuth) ~= "boolean" then
control.allowInsecureAuth = true
end
local disable_device_auth = optional_bool_env("GATEWAY_DISABLE_DEVICE_AUTH")
if disable_device_auth ~= nil then
control.dangerouslyDisableDeviceAuth = disable_device_auth
elseif type(control.dangerouslyDisableDeviceAuth) ~= "boolean" then
control.dangerouslyDisableDeviceAuth = true
end
control.dangerouslyAllowHostHeaderOriginFallback = bool_env("GATEWAY_HOST_HEADER_FALLBACK")
local env = ensure_table(cfg, "env")
local selected_env_key = optional_env("DEFAULT_AGENT_KEY")
local selected_api_key = optional_env("DEFAULT_AGENT_API_KEY")
if selected_env_key and selected_api_key then
env[selected_env_key] = selected_api_key
end
cfg.env = next(env) and env or nil
local function update_api_key(provider)
if selected_api_key ~= nil then
provider.apiKey = selected_api_key ~= "" and selected_api_key or nil
end
end
local function env_key_for_provider(id)
if id == "openai" then return "OPENAI_API_KEY" end
if id == "anthropic" then return "ANTHROPIC_API_KEY" end
if id == "minimax-cn" then return "MINIMAX_API_KEY" end
if id == "moonshot" then return "MOONSHOT_API_KEY" end
return nil
end
local models = ensure_table(cfg, "models")
if type(models.mode) ~= "string" or models.mode == "" then
models.mode = "merge"
end
local current_provider_id = optional_env("DEFAULT_AGENT_NAME")
local primary_model = optional_env("DEFAULT_AGENT_MODEL")
local current_primary = cfg.agents and cfg.agents.defaults and cfg.agents.defaults.model and cfg.agents.defaults.model.primary
if not primary_model and type(current_primary) == "string" and current_primary ~= "" then
primary_model = current_primary
end
if not current_provider_id and type(primary_model) == "string" and primary_model:match("^[^/]+/.+") then
current_provider_id = primary_model:match("^([^/]+)/")
end
if not current_provider_id or current_provider_id == "" then
current_provider_id = "anthropic"
end
if not primary_model or primary_model == "" then
if current_provider_id == "openai" then
primary_model = "openai/gpt-5.2"
elseif current_provider_id == "anthropic" then
primary_model = "anthropic/claude-sonnet-4-6"
elseif current_provider_id == "minimax-cn" then
primary_model = "minimax-cn/MiniMax-M2.5"
elseif current_provider_id == "moonshot" then
primary_model = "moonshot/kimi-k2.5"
elseif current_provider_id == "custom-provider" then
primary_model = "custom-provider/custom-model"
local custom = models.providers and models.providers["custom-provider"]
local first = custom and custom.models and custom.models[1]
if type(first) == "table" and type(first.id) == "string" and first.id ~= "" then
primary_model = "custom-provider/" .. first.id
end
else
primary_model = "anthropic/claude-sonnet-4-6"
end
end
local current_model_id = primary_model:match("^[^/]+/(.+)$") or primary_model
if current_model_id == "" then
current_model_id = "claude-sonnet-4-6"
end
local old_providers = type(models.providers) == "table" and models.providers or {}
local preserved_provider = type(old_providers[current_provider_id]) == "table" and old_providers[current_provider_id] or {}
local providers = {}
models.providers = providers
local current_provider = preserved_provider
providers[current_provider_id] = current_provider
if current_provider_id == "openai" then
current_provider.api = "openai-completions"
current_provider.baseUrl = "https://api.openai.com/v1"
update_api_key(current_provider)
current_provider.models = {
{ id = current_model_id, name = current_model_id },
}
elseif current_provider_id == "anthropic" then
current_provider.api = "anthropic-messages"
current_provider.baseUrl = "https://api.anthropic.com"
update_api_key(current_provider)
current_provider.models = {
{ id = current_model_id, name = current_model_id },
}
elseif current_provider_id == "minimax-cn" then
current_provider.api = "anthropic-messages"
current_provider.baseUrl = "https://api.minimaxi.com/anthropic"
update_api_key(current_provider)
current_provider.authHeader = true
current_provider.models = {
{ id = current_model_id, name = current_model_id },
}
elseif current_provider_id == "moonshot" then
current_provider.api = "openai-completions"
current_provider.baseUrl = "https://api.moonshot.cn/v1"
update_api_key(current_provider)
current_provider.models = {
{ id = current_model_id, name = current_model_id },
}
elseif current_provider_id == "custom-provider" then
if current_model_id == "" then
current_model_id = "custom-model"
end
current_provider.api = "openai-completions"
current_provider.baseUrl = optional_env("DEFAULT_AGENT_OVERRIDE_BASE_URL") or current_provider.baseUrl
update_api_key(current_provider)
current_provider.authHeader = nil
current_provider.models = {
{ id = current_model_id, name = current_model_id },
}
end
local override_base = optional_env("DEFAULT_AGENT_OVERRIDE_BASE_URL") or ""
local base_url_mode = optional_env("DEFAULT_AGENT_BASE_URL_MODE") or ""
if current_provider_id == "custom-provider" and override_base ~= "" then
current_provider.baseUrl = override_base
elseif base_url_mode == "override" and override_base ~= "" then
current_provider.baseUrl = override_base
elseif base_url_mode == "default" then
current_provider.baseUrl = optional_env("DEFAULT_AGENT_BASE_URL") or current_provider.baseUrl
end
local agents = ensure_table(cfg, "agents")
local defaults = ensure_table(agents, "defaults")
local model = ensure_table(defaults, "model")
model.primary = primary_model or "anthropic/claude-sonnet-4-6"
local current_env_key = env_key_for_provider(current_provider_id)
local next_env = {}
if current_env_key and type(current_provider.apiKey) == "string" and current_provider.apiKey ~= "" then
next_env[current_env_key] = current_provider.apiKey
end
cfg.env = next(next_env) and next_env or nil
prune_empty_tables(cfg)
local encoded = json.stringify(cfg, true)
if encoded then
encoded = encoded:gsub("\\/", "/")
end
local f = assert(io.open(cfg_path, "w"))
f:write(encoded or "{}")
f:write("\n")
f:close()
local env_path = cfg_path:gsub("openclaw%.json$", "openclaw.env")
local envf = assert(io.open(env_path, "w"))
if selected_env_key and selected_env_key ~= "" and selected_api_key and selected_api_key ~= "" then
local val = tostring(selected_api_key)
val = val:gsub("\\", "\\\\"):gsub("\n", "\\n"):gsub('"', '\\"')
envf:write(selected_env_key .. '="' .. val .. '"\n')
end
envf:close()
EOF
}
have_local_node_runtime() {
[ -x "$NODE_BIN" ] && [ -x "$NPM_BIN" ]
}
cached_node_archive_ok() {
local archive="$1"
[ -s "$archive" ] || return 1
if command -v xz >/dev/null 2>&1; then
xz -t "$archive" >/dev/null 2>&1
return $?
fi
tar -tJf "$archive" >/dev/null 2>&1
}
install_node() {
local arch libc url tmp
arch="$(detect_arch)"
[ "$arch" != "unsupported" ] || { write_installer_log "Unsupported arch"; return 1; }
if is_musl; then
libc="musl"
else
libc="glibc"
fi
# OpenWrt/iStoreOS runs on musl; use musl builds by default.
url="https://unofficial-builds.nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-${arch}-musl.tar.xz"
if have_local_node_runtime; then
write_installer_log "Using existing Node.js runtime: $($NODE_BIN --version 2>/dev/null || echo unknown)"
return 0
fi
if [ -x "$NODE_BIN" ] || [ -x "$NPM_BIN" ]; then
write_installer_log "Node.js runtime under $NODE_DIR is incomplete; reinstalling bundled Node.js"
fi
# Fallback: use system node/npm (e.g. from opkg) and symlink into BASE_DIR.
local sys_node sys_npm
sys_node="$(command -v node 2>/dev/null || true)"
sys_npm="$(command -v npm 2>/dev/null || true)"
if [ -n "$sys_node" ] && [ -n "$sys_npm" ]; then
mkdir -p "${NODE_DIR}/bin" 2>/dev/null || true
ln -sf "$sys_node" "${NODE_DIR}/bin/node" 2>/dev/null || true
ln -sf "$sys_npm" "${NODE_DIR}/bin/npm" 2>/dev/null || true
if have_local_node_runtime; then
write_installer_log "Using system Node.js: $($NODE_BIN --version 2>/dev/null || echo unknown)"
return 0
fi
fi
write_installer_log "Downloading Node.js v${NODE_VERSION} (${arch}, ${libc})"
tmp="/tmp/${APP}-node-${NODE_VERSION}.tar.xz"
if cached_node_archive_ok "$tmp"; then
write_installer_log "Reusing cached Node.js archive: $tmp"
else
if [ -e "$tmp" ]; then
write_installer_log "Cached Node.js archive is invalid, re-downloading: $tmp"
rm -f "$tmp" 2>/dev/null || true
fi
if ! download "$url" "$tmp"; then
write_installer_log "Node.js download failed"
return 1
fi
fi
rm -rf "$NODE_DIR" 2>/dev/null || true
mkdir -p "$NODE_DIR"
if command -v xz >/dev/null 2>&1; then
xz -dc "$tmp" | tar -xf - -C "$NODE_DIR" --strip-components=1
else
tar -xJf "$tmp" -C "$NODE_DIR" --strip-components=1
fi
rm -f "$tmp" 2>/dev/null || true
[ -x "$NODE_BIN" ] || return 1
write_installer_log "Node.js installed: $($NODE_BIN --version 2>/dev/null || echo unknown)"
return 0
}
install_openclaw() {
local flags="" npm_registry="" effective_registry=""
[ -x "$NPM_BIN" ] || return 1
ensure_git() {
ensure_remote_https() {
command -v git >/dev/null 2>&1 || return 1
local execp
execp="$(git --exec-path 2>/dev/null || true)"
[ -n "$execp" ] || return 1
[ -x "$execp/git-remote-https" ] && return 0
# Some builds ship helpers under libexec paths; exec-path should cover it,
# but keep a fallback in case of unusual layouts.
[ -x /usr/lib/git-core/git-remote-https ] && return 0
[ -x /usr/libexec/git-core/git-remote-https ] && return 0
return 1
}
command -v git >/dev/null 2>&1 || {
write_installer_log "git not found; some npm dependencies require git. Trying to install git via opkg."
local pm=""
if command -v is-opkg >/dev/null 2>&1; then
pm="is-opkg"
elif command -v opkg >/dev/null 2>&1; then
pm="opkg"
fi
if [ -z "$pm" ]; then
write_installer_log "git is required but opkg/is-opkg is not available; please install git and retry."
return 1
fi
if "$pm" install git >/dev/null 2>&1; then
command -v git >/dev/null 2>&1 || true
fi
}
command -v git >/dev/null 2>&1 || {
write_installer_log "git is required but could not be installed automatically; please opkg install git and retry."
return 1
}
# Ensure HTTPS transport is available (git-remote-https), required for git URL rewrites.
if ensure_remote_https; then
return 0
fi
write_installer_log "git HTTPS transport helper not found (git-remote-https); trying to install git-http/git-https."
local pm=""
if command -v is-opkg >/dev/null 2>&1; then
pm="is-opkg"
elif command -v opkg >/dev/null 2>&1; then
pm="opkg"
fi
if [ -z "$pm" ]; then
write_installer_log "git-remote-https is required but opkg/is-opkg is not available; please install git-http (or equivalent) and retry."
return 1
fi
"$pm" install git-http >/dev/null 2>&1 || true
"$pm" install git-https >/dev/null 2>&1 || true
if ensure_remote_https; then
return 0
fi
write_installer_log "git-remote-https is required but could not be installed automatically; please opkg install git-http (or git-https) and retry."
return 1
}
ensure_git || return 1
if is_musl; then
flags="--ignore-scripts"
fi
if [ "${INSTALL_ACCELERATED:-1}" = "1" ]; then
npm_registry="https://registry.npmmirror.com"
fi
local target_pkg=""
target_pkg="$(target_openclaw_package)"
write_installer_log "Installing OpenClaw from npm (${target_pkg})"
write_installer_log "Node.js: $(PATH=\"${NODE_DIR}/bin:/usr/sbin:/usr/bin:/sbin:/bin\" \"$NODE_BIN\" --version 2>/dev/null || echo unknown), npm: $(PATH=\"${NODE_DIR}/bin:/usr/sbin:/usr/bin:/sbin:/bin\" \"$NPM_BIN\" --version 2>/dev/null || echo unknown)"
write_installer_log "npm prefix: $GLOBAL_DIR, cache: ${BASE_DIR}/npm-cache, musl: $(is_musl && echo yes || echo no) ${flags:+($flags)}"
write_installer_log "npm registry: ${npm_registry:-default}"
if [ -n "$npm_registry" ]; then
effective_registry="$(HOME="$DATA_DIR" npm_config_cache="${BASE_DIR}/npm-cache" npm_config_registry="$npm_registry" \
PATH="${NODE_DIR}/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
"$NPM_BIN" config get registry 2>/dev/null | tr -d '\r' | head -n 1)"
else
effective_registry="$(HOME="$DATA_DIR" npm_config_cache="${BASE_DIR}/npm-cache" \
PATH="${NODE_DIR}/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
"$NPM_BIN" config get registry 2>/dev/null | tr -d '\r' | head -n 1)"
fi
write_installer_log "npm effective registry: ${effective_registry:-unknown}"
rm -rf "${GLOBAL_DIR}/lib/node_modules/openclaw" 2>/dev/null || true
local tmp="/tmp/${APP}-npm-install.log"
rm -f "$tmp" 2>/dev/null || true
# Some npm dependencies may be referenced as git+ssh (e.g. git@github.com:...),
# which fails on routers without SSH keys. Force git to use HTTPS by writing a
# scoped gitconfig under DATA_DIR and setting HOME for this invocation.
if command -v git >/dev/null 2>&1; then
mkdir -p "$DATA_DIR" 2>/dev/null || true
# Use --add because url.*.insteadOf is multi-valued (git config without --add overwrites).
git config --file "$DATA_DIR/.gitconfig" --add url."https://github.com/".insteadOf "ssh://git@github.com/" 2>/dev/null || true
git config --file "$DATA_DIR/.gitconfig" --add url."https://github.com/".insteadOf "ssh://git@github.com" 2>/dev/null || true
git config --file "$DATA_DIR/.gitconfig" --add url."https://github.com/".insteadOf "git@github.com:" 2>/dev/null || true
fi
# Stream npm output to taskd log while keeping a local copy for post-mortem.
: >"$tmp"
tail -n 0 -f "$tmp" &
local tailpid="$!"
write_installer_log "npm install started (may be quiet for a while); streaming npm output as it arrives"
# npm from Node tarball uses `#!/usr/bin/env node`, ensure PATH contains our node.
if [ -n "$npm_registry" ]; then
HOME="$DATA_DIR" npm_config_cache="${BASE_DIR}/npm-cache" npm_config_registry="$npm_registry" \
PATH="${NODE_DIR}/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
"$NPM_BIN" install -g "$target_pkg" --prefix="$GLOBAL_DIR" \
$flags --no-audit --no-fund --no-progress >"$tmp" 2>&1 &
else
HOME="$DATA_DIR" npm_config_cache="${BASE_DIR}/npm-cache" \
PATH="${NODE_DIR}/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
"$NPM_BIN" install -g "$target_pkg" --prefix="$GLOBAL_DIR" \
$flags --no-audit --no-fund --no-progress >"$tmp" 2>&1 &
fi
local npmpid="$!"
local start_ts last_change_ts last_size last_lines
start_ts="$(date +%s 2>/dev/null || echo 0)"
last_change_ts="$start_ts"
last_size=0
last_lines=0
# Heartbeat: emit periodic logs so the UI doesn't appear stuck.
while kill -0 "$npmpid" 2>/dev/null; do
sleep 30
kill -0 "$npmpid" 2>/dev/null || break
local now_ts size lines quiet_s elapsed_s elapsed_h
now_ts="$(date +%s 2>/dev/null || echo 0)"
size="$(wc -c <"$tmp" 2>/dev/null || echo 0)"
lines="$(wc -l <"$tmp" 2>/dev/null || echo 0)"
case "$size" in ''|*[!0-9]*) size=0 ;; esac
case "$lines" in ''|*[!0-9]*) lines=0 ;; esac
if [ "$size" -ne "$last_size" ] || [ "$lines" -ne "$last_lines" ]; then
last_size="$size"
last_lines="$lines"
last_change_ts="$now_ts"
fi
quiet_s=$((now_ts - last_change_ts))
elapsed_s=$((now_ts - start_ts))
elapsed_h="$(fmt_elapsed "$elapsed_s")"
if [ "$quiet_s" -ge 60 ]; then
local last
last="$(tail -n 1 "$tmp" 2>/dev/null | tr -d '\r' || true)"
if [ -n "$last" ]; then
write_installer_log "npm install running (${elapsed_h} elapsed; no new output for ${quiet_s}s; last: ${last})"
else
write_installer_log "npm install running (${elapsed_h} elapsed; no output yet)"
fi
fi
done
local npm_rc=0
wait "$npmpid" || npm_rc=$?
if [ "$npm_rc" -ne 0 ]; then
write_installer_log "npm install failed (rc=$npm_rc); dumping recent output"
kill "$tailpid" 2>/dev/null || true
wait "$tailpid" 2>/dev/null || true
rm -f "$tmp" 2>/dev/null || true
return 1
fi
local end_ts elapsed_s elapsed_h
end_ts="$(date +%s 2>/dev/null || echo 0)"
elapsed_s=$((end_ts - start_ts))
elapsed_h="$(fmt_elapsed "$elapsed_s")"
write_installer_log "npm install finished (${elapsed_h} elapsed)"
kill "$tailpid" 2>/dev/null || true
wait "$tailpid" 2>/dev/null || true
rm -f "$tmp" 2>/dev/null || true
find_entry >/dev/null 2>&1 || return 1
write_installer_log "OpenClaw installed: $(openclaw_version || echo unknown)"
return 0
}
upgrade_openclaw() {
local was_running=0
local st=""
if ! have_openclaw_runtime; then
write_installer_log "OpenClaw is not installed yet; fallback to fresh install."
install_openclaw
return $?
fi
st="$(do_status 2>/dev/null || true)"
if [ "$st" = "running" ]; then
was_running=1
write_installer_log "Stopping OpenClaw service before update"
/etc/init.d/openclawmgr stop >/dev/null 2>&1 || true
fi
write_installer_log "Updating OpenClaw via npm"
write_installer_log "Current version: $(openclaw_version || echo unknown)"
if ! install_openclaw; then
write_installer_log "npm update failed"
if [ "$was_running" = "1" ] || [ "${ENABLED:-0}" = "1" ]; then
write_installer_log "Trying to restore service after failed update"
/etc/init.d/openclawmgr start >/dev/null 2>&1 || true
fi
return 1
fi
ensure_gateway_config || true
fix_data_permissions || true
if [ "$was_running" = "1" ] || [ "${ENABLED:-0}" = "1" ]; then
ensure_safe_port_for_start || return 1
write_installer_log "Restarting OpenClaw service after update"
/etc/init.d/openclawmgr restart >/dev/null 2>&1 || /etc/init.d/openclawmgr start >/dev/null 2>&1 || true
fi
write_installer_log "OpenClaw updated: $(openclaw_version || echo unknown)"
return 0
}
do_install() {
acquire_lock
write_installer_log "== install begin =="
ensure_dirs
# Node version is bundled/managed by installer code; do not allow UCI override.
uci -q delete "${UCI_NS}.main.node_version" >/dev/null 2>&1 || true
uci -q commit "$UCI_NS" >/dev/null 2>&1 || true
# Ensure token exists even if runtime is already installed (service start requires it).
ensure_token
ensure_gateway_config || true
if have_openclaw_runtime; then
if [ "$ACTION" = "upgrade" ]; then
write_installer_log "OpenClaw is already installed: $(openclaw_version || echo unknown)"
upgrade_openclaw
return $?
fi
write_installer_log "OpenClaw is already installed: $(openclaw_version || echo unknown)"
write_installer_log "Skip install. Use restart/apply config if you only need to refresh configuration."
return 0
fi
if ! has_default_route; then
write_installer_log "Install requires internet access, but no default gateway is configured."
write_installer_log "Fix: configure WAN or add a default route, then retry Install."
return 1
fi
check_space_mb "$BASE_DIR" 2048 || return 1
install_node
install_openclaw
ensure_gateway_config || true
fix_data_permissions || true
# Do not auto-enable/start on first install; let user Save & Apply after choosing base_dir and settings.
if [ "${ENABLED:-0}" = "1" ]; then
ensure_safe_port_for_start || return 1
/etc/init.d/openclawmgr enable >/dev/null 2>&1 || true
/etc/init.d/openclawmgr restart >/dev/null 2>&1 || true
fi
write_installer_log "== install done =="
}
do_uninstall() {
acquire_lock
write_installer_log "== uninstall begin =="
/etc/init.d/openclawmgr stop >/dev/null 2>&1 || true
/etc/init.d/openclawmgr disable >/dev/null 2>&1 || true
uci -q set "${UCI_NS}.main.enabled=0" && uci -q commit "$UCI_NS" || true
rm -rf "$NODE_DIR" "$GLOBAL_DIR" 2>/dev/null || true
write_installer_log "Runtime removed (node/global)"
write_installer_log "== uninstall done =="
}
do_uninstall_openclaw() {
acquire_lock
write_installer_log "== uninstall openclaw begin =="
/etc/init.d/openclawmgr stop >/dev/null 2>&1 || true
/etc/init.d/openclawmgr disable >/dev/null 2>&1 || true
uci -q set "${UCI_NS}.main.enabled=0" && uci -q commit "$UCI_NS" || true
rm -rf "${GLOBAL_DIR}/lib/node_modules/openclaw" \
"${GLOBAL_DIR}/bin/openclaw" 2>/dev/null || true
write_installer_log "OpenClaw removed (node kept)"
write_installer_log "== uninstall openclaw done =="
}
do_purge() {
acquire_lock
write_installer_log "== purge begin =="
/etc/init.d/openclawmgr stop >/dev/null 2>&1 || true
/etc/init.d/openclawmgr disable >/dev/null 2>&1 || true
uci -q set "${UCI_NS}.main.enabled=0" && uci -q commit "$UCI_NS" || true
# Safety: only remove the whole base_dir when it looks like a dedicated OpenClawMgr directory.
# Otherwise, only remove managed subdirectories to avoid accidental system wipe.
if [ -f "${BASE_DIR}/.openclawmgr.managed" ] || [ "$(basename "$BASE_DIR" 2>/dev/null || echo "")" = "OpenClawMgr" ]; then
rm -rf "$BASE_DIR" 2>/dev/null || true
write_installer_log "Base dir removed: $BASE_DIR"
else
rm -rf "$NODE_DIR" "$GLOBAL_DIR" "$DATA_DIR" "$BASE_DIR/npm-cache" "${BASE_DIR}/.openclawmgr.managed" 2>/dev/null || true
write_installer_log "Base dir kept (safety). Removed: node/global/data/npm-cache under $BASE_DIR"
fi
write_installer_log "== purge done =="
}
do_start() {
acquire_lock
write_installer_log "== start begin =="
ensure_dirs
fix_data_permissions || true
ensure_token
ensure_safe_port_for_start || exit 1
/etc/init.d/openclawmgr enable >/dev/null 2>&1 || true
uci -q set "${UCI_NS}.main.enabled=1" && uci -q commit "$UCI_NS" || true
/etc/init.d/openclawmgr restart >/dev/null 2>&1 || /etc/init.d/openclawmgr start >/dev/null 2>&1 || true
write_installer_log "== start done =="
}
do_stop() {
acquire_lock
write_installer_log "== stop begin =="
/etc/init.d/openclawmgr stop >/dev/null 2>&1 || true
/etc/init.d/openclawmgr disable >/dev/null 2>&1 || true
uci -q set "${UCI_NS}.main.enabled=0" && uci -q commit "$UCI_NS" || true
write_installer_log "== stop done =="
}
do_restart() {
acquire_lock
write_installer_log "== restart begin =="
ensure_dirs
fix_data_permissions || true
ensure_token
ensure_safe_port_for_start || exit 1
/etc/init.d/openclawmgr restart >/dev/null 2>&1 || true
write_installer_log "== restart done =="
}
do_apply_config() {
acquire_lock
write_installer_log "== apply_config begin =="
ensure_dirs
fix_data_permissions || true
ensure_token
ensure_gateway_config || true
local pid=""
pid="$(ubus call service list "{\"name\":\"openclawmgr\"}" 2>/dev/null | jsonfilter -e '$.openclawmgr.instances.gateway.pid' 2>/dev/null || true)"
if [ "${ENABLED:-0}" = "1" ]; then
ensure_safe_port_for_start || exit 1
/etc/init.d/openclawmgr enable >/dev/null 2>&1 || true
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
/etc/init.d/openclawmgr restart >/dev/null 2>&1 || true
else
/etc/init.d/openclawmgr start >/dev/null 2>&1 || true
fi
else
/etc/init.d/openclawmgr stop >/dev/null 2>&1 || true
/etc/init.d/openclawmgr disable >/dev/null 2>&1 || true
fi
write_installer_log "== apply_config done =="
}
do_status() {
local entry
entry="$(find_entry 2>/dev/null || true)"
[ -n "$entry" ] || return 0
local pid=""
pid="$(ubus call service list "{\"name\":\"openclawmgr\"}" 2>/dev/null | jsonfilter -e '$.openclawmgr.instances.gateway.pid' 2>/dev/null || true)"
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
echo "running"
return 0
fi
echo "stopped"
}
do_port() { echo "$PORT"; }
do_token() { echo "${TOKEN:-}"; }
diag_paths() {
local name="$1"
DIAG_LOG="${DIAG_PREFIX}-${name}.log"
DIAG_PID="${DIAG_PREFIX}-${name}.pid"
DIAG_EXIT="${DIAG_PREFIX}-${name}.exit"
DIAG_LOCK="${DIAG_PREFIX}-${name}.lock"
}
diag_busy() {
local name="$1"
diag_paths "$name"
[ -d "$DIAG_LOCK" ] && return 0
return 1
}
diag_run() {
local name="$1" limit="${2:-200}"
case "$name" in
doctor|gateway_status|gateway_health|logs|channels_status) ;;
*) echo "unknown diag: $name" >&2; exit 1 ;;
esac
diag_paths "$name"
if ! mkdir "$DIAG_LOCK" 2>/dev/null; then
echo "busy" >&2
exit 2
fi
rm -f "$DIAG_EXIT" 2>/dev/null || true
: >"$DIAG_LOG"
echo "$$" >"$DIAG_PID"
(
set +e
echo "== $APP diag: $name =="
echo "time: $(log_ts)"
echo "base_dir: $BASE_DIR"
echo "config: ${DATA_DIR}/.openclaw/openclaw.json"
echo ""
case "$name" in
doctor)
openclaw_cmd doctor --no-color
;;
gateway_status)
openclaw_cmd gateway status --no-color --url "ws://127.0.0.1:${PORT}" --token "${TOKEN}"
;;
gateway_health)
openclaw_cmd gateway health --no-color --url "ws://127.0.0.1:${PORT}" --token "${TOKEN}"
;;
logs)
case "$limit" in ''|*[!0-9]*) limit=200 ;; esac
[ "$limit" -gt 2000 ] && limit=2000
[ "$limit" -lt 10 ] && limit=10
openclaw_cmd logs --plain --no-color --limit "$limit"
;;
channels_status)
openclaw_cmd channels status --no-color
;;
esac
rc=$?
echo ""
echo "exit_code: $rc"
exit "$rc"
) >>"$DIAG_LOG" 2>&1
rc=$?
echo "$rc" >"$DIAG_EXIT"
rm -f "$DIAG_PID" 2>/dev/null || true
rmdir "$DIAG_LOCK" 2>/dev/null || true
exit "$rc"
}
diag_poll() {
local name="$1"
case "$name" in
doctor|gateway_status|gateway_health|logs|channels_status) ;;
*) echo "unknown diag: $name" >&2; exit 1 ;;
esac
diag_paths "$name"
local running=0
if [ -f "$DIAG_PID" ]; then
local pid
pid="$(cat "$DIAG_PID" 2>/dev/null || true)"
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
running=1
fi
fi
if [ "$running" -eq 1 ]; then
echo "running"
exit 0
fi
if [ -f "$DIAG_EXIT" ]; then
echo "done:$(cat "$DIAG_EXIT" 2>/dev/null || echo -1)"
exit 0
fi
echo "idle"
}
ACTION="${1:-help}"
shift 1 || true
BASE_DIR="$(uci_get base_dir)"
ENABLED="$(uci_get enabled)"
PORT="$(uci_get port)"
BIND="$(uci_get bind)"
TOKEN="$(uci_get token)"
ALLOW_INSECURE_AUTH="$(uci_get allow_insecure_auth)"
DISABLE_DEVICE_AUTH="$(uci_get disable_device_auth)"
ALLOWED_ORIGINS="$(uci_get_list allowed_origins)"
DEFAULT_AGENT="$(uci_get default_agent)"
DEFAULT_MODEL="$(uci_get default_model)"
INSTALL_ACCELERATED="$(uci_get install_accelerated)"
PROVIDER_API_KEY="$(uci_get provider_api_key)"
PROVIDER_BASE_URL="$(uci_get provider_base_url)"
[ -n "$PORT" ] || PORT="18789"
[ -n "$BIND" ] || BIND="lan"
[ -n "$ENABLED" ] || ENABLED="0"
NODE_VERSION="24.14.0"
[ -n "$INSTALL_ACCELERATED" ] || INSTALL_ACCELERATED="1"
[ -n "$INSTALL_CHANNEL" ] || INSTALL_CHANNEL="stable"
case "$PORT" in
''|*[!0-9]*) PORT="18789" ;;
esac
if [ "$PORT" -lt 1 ] 2>/dev/null || [ "$PORT" -gt 65535 ] 2>/dev/null; then
PORT="18789"
fi
case "$BIND" in
loopback|lan|auto|tailnet|custom) ;;
*) BIND="lan" ;;
esac
case "$ENABLED" in
1|true|yes|on) ENABLED="1" ;;
*) ENABLED="0" ;;
esac
case "$ALLOW_INSECURE_AUTH" in
'') ;;
1|true|yes|on) ALLOW_INSECURE_AUTH="1" ;;
*) ALLOW_INSECURE_AUTH="0" ;;
esac
case "$DISABLE_DEVICE_AUTH" in
'') ;;
1|true|yes|on) DISABLE_DEVICE_AUTH="1" ;;
*) DISABLE_DEVICE_AUTH="0" ;;
esac
case "$DEFAULT_AGENT" in
'') ;;
openai|anthropic|minimax-cn|moonshot|custom-provider) ;;
*) DEFAULT_AGENT="anthropic" ;;
esac
case "$INSTALL_ACCELERATED" in
1|true|yes|on) INSTALL_ACCELERATED="1" ;;
*) INSTALL_ACCELERATED="0" ;;
esac
case "$INSTALL_CHANNEL" in
latest) INSTALL_CHANNEL="latest" ;;
*) INSTALL_CHANNEL="stable" ;;
esac
require_base_dir() {
if [ -z "$BASE_DIR" ]; then
write_installer_log "base_dir is not configured; please choose a data directory in LuCI and Save & Apply, then retry."
exit 2
fi
if ! validate_base_dir "$BASE_DIR"; then
write_installer_log "Refusing to operate on unsafe base_dir: $BASE_DIR"
write_installer_log "Fix: choose a dedicated directory like /root/Configs/OpenClawMgr or /mnt/.../OpenClawMgr"
exit 2
fi
}
ensure_safe_port_for_start() {
case "$PORT" in
''|*[!0-9]*) return 1 ;;
esac
if [ "$PORT" -le 1024 ] 2>/dev/null; then
write_installer_log "Refusing to start with unsafe port $PORT (must be >1024)."
return 1
fi
return 0
}
NODE_DIR="${BASE_DIR}/node"
GLOBAL_DIR="${BASE_DIR}/global"
DATA_DIR="${BASE_DIR}/data"
if [ -n "$BASE_DIR" ]; then
load_gateway_runtime_from_json
fi
case "$PORT" in
''|*[!0-9]*) PORT="18789" ;;
esac
if [ "$PORT" -lt 1 ] 2>/dev/null || [ "$PORT" -gt 65535 ] 2>/dev/null; then
PORT="18789"
fi
case "$BIND" in
loopback|lan|auto|tailnet|custom) ;;
*) BIND="lan" ;;
esac
NODE_BIN="${NODE_DIR}/bin/node"
NPM_BIN="${NODE_DIR}/bin/npm"
case "$ACTION" in
install|upgrade)
require_base_dir
do_install
;;
uninstall)
require_base_dir
do_uninstall
;;
uninstall_openclaw)
require_base_dir
do_uninstall_openclaw
;;
purge)
require_base_dir
do_purge
;;
rm)
require_base_dir
do_uninstall
;;
start)
require_base_dir
do_start
;;
stop)
require_base_dir
do_stop
;;
restart)
require_base_dir
do_restart
;;
apply_config)
require_base_dir
do_apply_config
;;
status)
[ -n "$BASE_DIR" ] || exit 0
do_status
;;
port)
do_port
;;
token)
do_token
;;
node_version)
node_version
;;
openclaw_version)
openclaw_version
;;
local_openclaw_version)
local_openclaw_version
;;
latest_openclaw_version)
latest_openclaw_version
;;
diag)
diag_run "${1:-}" "${2:-}"
;;
diag_poll)
diag_poll "${1:-}"
;;
*)
echo "Usage: $0 {install|upgrade|uninstall|uninstall_openclaw|purge|rm|start|stop|restart|status|port|token|node_version|openclaw_version|local_openclaw_version|latest_openclaw_version|diag|diag_poll}" >&2
exit 1
;;
esac