# syntax=docker/dockerfile:1 # ========================================== # OpenClaw for Hugging Face Spaces # Dockerfile v2.1.9(全功能|中文注释版) # # A/B 切换(HF Variables): # - GENERATE_NEW_OPENCLAW_CONFIG 不存在/空/0/NO/no/null/not => B方案(默认:恢复后合并修复) # - 其它任意值 => A方案(强制生成新 openclaw.json,不使用备份/恢复出来的 openclaw.json) # # B方案合并原则(核心): # - 旧配置 openclaw.json 中“模板没有的字段/结构”:保留不丢 # - 模板(由 Dockerfile 生成)里存在的字段:以模板为准(冲突模板优先) # - 数组:模板优先,但保留旧数组中模板没有的元素(不丢 old-only) # - 模型数组:按 id 合并,补齐缺失模型参数;模板值覆盖冲突;旧配置额外模型保留 # - env 优先:仅当 env 真正有值时,才覆盖旧配置敏感字段(密码/API Key 等) # # 备份: # - BACKUP_INTERVAL 来自 HF env(秒),不硬编码;未设默认 10800 # * <=0 禁用备份;1~59 自动提升到 60;非数字回退 10800 # - HF_DATASET + HF_TOKEN 配置后才启用备份/恢复 # ========================================== FROM node:22-slim # -------------------------- # 1) 系统依赖 # -------------------------- RUN apt-get update && apt-get install -y --no-install-recommends \ git openssh-client build-essential python3 python3-pip \ g++ make ca-certificates curl jq \ && rm -rf /var/lib/apt/lists/* # -------------------------- # 2) Hugging Face Hub(用于 dataset 备份/恢复) # -------------------------- RUN pip3 install --no-cache-dir huggingface_hub --break-system-packages # -------------------------- # 3) 证书与 git # -------------------------- RUN update-ca-certificates && \ git config --global http.sslVerify true && \ git config --global url."https://github.com/".insteadOf ssh://git@github.com/ # -------------------------- # 4) 安装 OpenClaw # -------------------------- RUN npm install -g openclaw@latest --unsafe-perm # -------------------------- # 5) 基础环境变量 # - BACKUP_INTERVAL 不硬编码(必须从 HF env 读取) # -------------------------- ENV PORT=7860 \ OPENCLAW_GATEWAY_MODE=local \ HOME=/root # ========================================== # 6) sync.py:restore + backup(安全解包) # ========================================== RUN cat > /usr/local/bin/sync.py <<'PY' #!/usr/bin/env python3 # -*- coding: utf-8 -*- import os, sys, tarfile from huggingface_hub import HfApi, hf_hub_download, login from datetime import datetime, timedelta api = HfApi() repo_id = os.getenv("HF_DATASET") token = os.getenv("HF_TOKEN") BASE_DIR = "/root/.openclaw" CONFIG_PATH = os.path.join(BASE_DIR, "openclaw.json") def is_safe_arc(arcpath: str) -> bool: # 防止路径遍历:禁止绝对路径与 .. 段 return not (arcpath.startswith("/") or arcpath.startswith("..") or "/../" in arcpath) def safe_extract(tar: tarfile.TarFile, path: str) -> None: """ 安全解包:确保每个成员最终落在目标目录下,避免 tar 路径穿越。 """ base = os.path.realpath(path) for m in tar.getmembers(): # 跳过链接/特殊文件 if m.issym() or m.islnk() or m.ischr() or m.isblk() or m.isfifo(): continue name = m.name or "" # 快速拒绝明显危险的 name if not is_safe_arc(name): continue dest = os.path.realpath(os.path.join(base, name)) if dest != base and not dest.startswith(base + os.sep): # 仍然可能穿越 continue tar.extract(m, path=base) def restore() -> bool: try: if not repo_id or not token: print("[SYNC] 跳过恢复: 未配置 HF_DATASET 或 HF_TOKEN") return True try: login(token=token) except Exception: pass files = api.list_repo_files(repo_id=repo_id, repo_type="dataset", token=token) now = datetime.now() for i in range(5): name = f"backup_{(now - timedelta(days=i)).strftime('%Y-%m-%d')}.tar.gz" if name in files: print(f"[SYNC] 下载并恢复: {name}") path = hf_hub_download(repo_id=repo_id, filename=name, repo_type="dataset", token=token) with tarfile.open(path, "r:gz") as tar: safe_extract(tar, BASE_DIR) print("[SYNC] 恢复成功") return True print("[SYNC] 未找到最近 5 天备份(首次部署/无备份属正常情况)") return True except Exception as e: print(f"[SYNC] 恢复异常: {e}") return False def backup() -> bool: try: if not repo_id or not token: print("[SYNC] 跳过备份: 未配置 HF_DATASET 或 HF_TOKEN") return True try: login(token=token) except Exception: pass day = datetime.now().strftime("%Y-%m-%d") out_path = f"/tmp/backup_{day}.tar.gz" print(f"[SYNC] 正在备份: {out_path}") # 备份范围(保留 A 方案能力) targets = ["sessions", "workspace", "agents", "cron", "identity", "devices"] with tarfile.open(out_path, "w:gz") as tar: for t in targets: full = os.path.join(BASE_DIR, t) if not os.path.exists(full): continue for root, dirs, files in os.walk(full): # 避免打包 credentials(敏感) dirs[:] = [d for d in dirs if d != "credentials"] for f in files: fp = os.path.join(root, f) arc = os.path.relpath(fp, BASE_DIR) if is_safe_arc(arc): tar.add(fp, arcname=arc) # 同时备份 openclaw.json if os.path.exists(CONFIG_PATH): tar.add(CONFIG_PATH, arcname="openclaw.json") print(f"[SYNC] 上传备份到 dataset: {repo_id}") api.upload_file( path_or_fileobj=out_path, path_in_repo=f"backup_{day}.tar.gz", repo_id=repo_id, repo_type="dataset", token=token ) print("[SYNC] 备份上传成功") return True except Exception as e: print(f"[SYNC] 备份失败: {e}") return False if __name__ == "__main__": cmd = sys.argv[1] if len(sys.argv) > 1 else "restore" if cmd == "backup": sys.exit(0 if backup() else 1) else: sys.exit(0 if restore() else 1) PY RUN chmod +x /usr/local/bin/sync.py # ========================================== # 7) generate-config.sh:生成“模板配置”(TEMPLATE) # - 包含 A 方案功能:trustedProxies、controlUi 等 # - 完整模型列表:5*nvidia + 2*openrouterai + 1*vercel # - 不包含 __comment/meta(严格 schema) # ========================================== RUN cat > /usr/local/bin/generate-config.sh <<'BASH' #!/bin/bash generate_config() { OUT="${1:-/root/.openclaw/openclaw.json}" GATEWAY_PORT=${PORT:-7860} # 默认路由(可被 HF env 覆盖) PROVIDER=${PROVIDER:-"nvidia"} MODEL=${MODEL:-"moonshotai/kimi-k2.5"} # Provider 基础参数(可被 HF env 覆盖) NVIDIA_BASE=${OPENAI_API_BASE:-"https://integrate.api.nvidia.com/v1"} # 注意:不要用 HF_TOKEN 作为 NVIDIA key fallback(HF_TOKEN 是 HF 登录 token) NVIDIA_KEY=${OPENAI_API_KEY:-""} OR_KEY=${OPENROUTER_API_KEY:-""} VERCEL_KEY=${VERCEL_AI_GATEWAY_API_KEY:-""} # 网关密码(HF env 优先;不设置时模板默认) GATEWAY_PASSWORD=${OPENCLAW_GATEWAY_PASSWORD:-"default_change_me"} cat > "$OUT" << EOF { "models": { "providers": { "nvidia": { "baseUrl": "${NVIDIA_BASE}", "apiKey": "${NVIDIA_KEY}", "api": "openai-completions", "models": [ { "id": "z-ai/glm4.7", "name": "GLM", "contextWindow": 131072, "reasoning": false, "input": ["text"], "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, "maxTokens": 131072 }, { "id": "deepseek-ai/deepseek-v3.2", "name": "DeepSeek V3.2", "contextWindow": 128000, "reasoning": false, "input": ["text"], "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, "maxTokens": 8192 }, { "id": "minimaxai/minimax-m2.1", "name": "MiniMax M2.1", "contextWindow": 128000, "reasoning": false, "input": ["text"], "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, "maxTokens": 8192 }, { "id": "moonshotai/kimi-k2.5", "name": "Kimi K2.5", "contextWindow": 262144, "reasoning": false, "input": ["text"], "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, "maxTokens": 8192 }, { "id": "qwen/qwen3-coder-480b-a35b-instruct", "name": "Qwen3 Coder 480B", "contextWindow": 262144, "reasoning": false, "input": ["text"], "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, "maxTokens": 8192 } ] }, "openrouterai": { "baseUrl": "https://openrouter.ai/api/v1", "apiKey": "${OR_KEY}", "api": "openai-completions", "models": [ { "id": "openrouter/pony-alpha", "name": "pony alpha", "contextWindow": 200000, "reasoning": false, "input": ["text"], "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, "maxTokens": 131000 }, { "id": "arcee-ai/trinity-large-preview:free", "name": "trinity large preview free", "contextWindow": 131000, "reasoning": false, "input": ["text"], "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, "maxTokens": 8192 } ] }, "vercel": { "baseUrl": "https://ai-gateway.vercel.sh/v1", "apiKey": "${VERCEL_KEY}", "api": "openai-completions", "models": [ { "id": "kwaipilot/kat-coder-pro-v1", "name": "kat coder pro v1", "contextWindow": 256000, "reasoning": false, "input": ["text"], "cost": { "input": 0.03, "output": 1.20, "cacheRead": 0.06, "cacheWrite": 0 }, "maxTokens": 32000 } ] } } }, "agents": { "defaults": { "model": { "primary": "${PROVIDER}/${MODEL}" }, "maxConcurrent": 4, "subagents": { "maxConcurrent": 8 } } }, "commands": { "native": "auto", "nativeSkills": "auto", "restart": true }, "messages": { "ackReactionScope": "group-mentions" }, "gateway": { "port": ${GATEWAY_PORT}, "mode": "local", "bind": "lan", "trustedProxies": ["0.0.0.0/0", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"], "auth": { "mode": "token", "token": "${GATEWAY_PASSWORD}" }, "controlUi": { "allowInsecureAuth": true } } } EOF } BASH RUN chmod +x /usr/local/bin/generate-config.sh # ========================================== # 8) start.sh:启动脚本(A/B 切换 + 深合并 + 备份) # ========================================== RUN cat > /usr/local/bin/start.sh <<'BASH' #!/bin/bash set -e GATEWAY_PORT=${PORT:-7860} # BACKUP_INTERVAL:HF env 读取(不硬编码) RAW_BI="${BACKUP_INTERVAL:-10800}" if echo "$RAW_BI" | grep -Eq '^[0-9]+$'; then BI="$RAW_BI" else echo "[WARN] BACKUP_INTERVAL 非数字: '${RAW_BI}', 回退为 10800" BI="10800" fi # <=0 禁用备份;1~59 提升到 60,避免过频 BACKUP_ENABLED=true if [ "$BI" -le 0 ]; then BACKUP_ENABLED=false elif [ "$BI" -lt 60 ]; then echo "[WARN] BACKUP_INTERVAL=${BI} 太小,提升到 60" BI="60" fi # GENERATE_NEW_OPENCLAW_CONFIG:不存在/空/0/no/null/not => B;其它 => 强制新(A) RAW_FLAG="${GENERATE_NEW_OPENCLAW_CONFIG-}" FLAG_NORM="$(printf '%s' "$RAW_FLAG" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" FORCE_NEW=false case "${FLAG_NORM}" in ""|"0"|"no"|"null"|"not") FORCE_NEW=false ;; *) FORCE_NEW=true ;; esac echo "===== Application Startup (v2.1.9) =====" echo "FORCE_NEW_OPENCLAW_CONFIG: ${FORCE_NEW} (GENERATE_NEW_OPENCLAW_CONFIG='${RAW_FLAG}')" echo "PORT: ${GATEWAY_PORT}" echo "BACKUP_INTERVAL(env): ${BI}s (enabled=${BACKUP_ENABLED})" mkdir -p /root/.openclaw/{sessions,workspace,credentials,agents/main/sessions,cron,identity,devices} command -v jq >/dev/null 2>&1 || { echo "[FATAL] jq 不存在"; exit 1; } # 1) restore(先恢复数据) echo "[START] Restore from HF Dataset..." set +e python3 /usr/local/bin/sync.py restore RESTORE_CODE=$? set -e if [ $RESTORE_CODE -ne 0 ]; then echo "[WARN] restore 失败(代码: $RESTORE_CODE),将继续启动" fi # 2) 永远生成模板(模板读取 HF env) TEMPLATE_FULL="/tmp/openclaw.template.full.json" source /usr/local/bin/generate-config.sh generate_config "$TEMPLATE_FULL" # 2.1 生成“合并用模板”(TEMPLATE_MERGE) # 目的:避免 env 未设置时,模板默认值误覆盖旧配置的敏感字段(密码/API Key) TEMPLATE_MERGE="/tmp/openclaw.template.merge.json" cp "$TEMPLATE_FULL" "$TEMPLATE_MERGE" # 如果 env 没设置密码,则合并模板里把 token 置 null(不覆盖旧 token) if [ -z "${OPENCLAW_GATEWAY_PASSWORD:-}" ]; then tmp=$(mktemp) jq '.gateway.auth.token = null' "$TEMPLATE_MERGE" > "$tmp" && mv "$tmp" "$TEMPLATE_MERGE" fi # 如果 env 没设置对应 key,则合并模板里把 apiKey 置 null(不覆盖旧 key) if [ -z "${OPENAI_API_KEY:-}" ]; then tmp=$(mktemp) jq '.models.providers.nvidia.apiKey = null' "$TEMPLATE_MERGE" > "$tmp" && mv "$tmp" "$TEMPLATE_MERGE" fi if [ -z "${OPENROUTER_API_KEY:-}" ]; then tmp=$(mktemp) jq '.models.providers.openrouterai.apiKey = null' "$TEMPLATE_MERGE" > "$tmp" && mv "$tmp" "$TEMPLATE_MERGE" fi if [ -z "${VERCEL_AI_GATEWAY_API_KEY:-}" ]; then tmp=$(mktemp) jq '.models.providers.vercel.apiKey = null' "$TEMPLATE_MERGE" > "$tmp" && mv "$tmp" "$TEMPLATE_MERGE" fi # 3) A:强制新配置(忽略 restore 的 openclaw.json) if [ "${FORCE_NEW}" = "true" ]; then echo "[MODE] A: 强制生成新 openclaw.json(忽略 restore 的 openclaw.json)" cp "$TEMPLATE_FULL" /root/.openclaw/openclaw.json else # 4) B:合并策略 if [ ! -f /root/.openclaw/openclaw.json ]; then echo "[MODE] B: 未发现 restore 的 openclaw.json => 直接使用模板生成" cp "$TEMPLATE_FULL" /root/.openclaw/openclaw.json else echo "[MODE] B: 合并 openclaw.json(模板优先 + 保留旧独有字段 + 数组不丢 old-only)" # 4.1 清理历史 __comment(严格 schema) CLEAN="/tmp/openclaw.cleaned.json" jq ' def walk(f): . as $in | if type=="object" then reduce keys[] as $k ({}; . + {($k): ($in[$k] | walk(f))}) | f elif type=="array" then map(walk(f)) | f else f end; walk(if type=="object" then del(."__comment") else . end) ' /root/.openclaw/openclaw.json > "$CLEAN" # 4.2 深合并:模板优先;数组不丢 old-only;对象数组按 id 合并 MERGED="/tmp/openclaw.merged.json" jq -s ' def obj_with_id: (type=="object" and (.id? != null)); # 去重合并:模板数组在前,旧数组追加旧独有元素 def union_by_json(a;b): ((b // []) + (a // [])) | unique_by(tojson); # 对象数组(有 id)按 id 合并:模板优先;旧额外 id 保留;无 id 项保留 def merge_array_by_id(a;b): (a // []) as $a | (b // []) as $b | ($a | map(select(obj_with_id)) | map({key: .id, value: .}) | from_entries) as $idx | ($b | map(if obj_with_id then rmerge(($idx[.id] // {}); .) else . end)) as $b_merged | ($b | map(select(obj_with_id) | .id)) as $b_ids | ($a | map(select(obj_with_id and (.id as $id | ($b_ids | index($id)) == null)))) as $a_extra_id | ($a | map(select(obj_with_id | not))) as $a_no_id | union_by_json(($a_extra_id + $a_no_id); $b_merged); # 递归合并:对象深合并;冲突模板(b)覆盖旧(a) def rmerge(a;b): if (a|type)=="object" and (b|type)=="object" then reduce ((a|keys_unsorted) + (b|keys_unsorted) | unique)[] as $k ({}; . + {($k): if (a[$k] != null and b[$k] != null) then rmerge(a[$k]; b[$k]) elif (b[$k] != null) then b[$k] else a[$k] end }) elif (a|type)=="array" and (b|type)=="array" then (b | map(obj_with_id) | any) as $hasid | if $hasid then merge_array_by_id(a;b) else union_by_json(a;b) end else b end; rmerge(.[0]; .[1]) ' "$CLEAN" "$TEMPLATE_MERGE" > "$MERGED" || { echo "[FATAL] jq 合并失败"; exit 1; } # 4.3 强制关键字段合法(PORT/env 应生效,bind 必须是合法枚举) jq --arg port "${GATEWAY_PORT}" ' .gateway |= (. // {}) | .gateway.port = ($port | tonumber) | .gateway.bind = ( if (.gateway.bind | type) != "string" then "lan" elif (.gateway.bind=="lan" or .gateway.bind=="loopback" or .gateway.bind=="tailnet" or .gateway.bind=="auto") then .gateway.bind else "lan" end ) ' "$MERGED" > /root/.openclaw/openclaw.json fi fi # 5) env patch:仅当 env 有值时覆盖敏感字段;否则保留旧值;缺失则回填默认保证 schema # 5.1 gateway.auth.token if [ -n "${OPENCLAW_GATEWAY_PASSWORD:-}" ]; then tmp=$(mktemp) jq --arg t "${OPENCLAW_GATEWAY_PASSWORD}" ' .gateway |= (. // {}) | .gateway.auth |= (. // {}) | .gateway.auth.mode = "token" | .gateway.auth.token = $t ' /root/.openclaw/openclaw.json > "$tmp" && mv "$tmp" /root/.openclaw/openclaw.json else # env 没设:若 token 不存在/不是字符串/为空,回填默认 tmp=$(mktemp) jq ' .gateway |= (. // {}) | .gateway.auth |= (. // {}) | .gateway.auth.mode = "token" | .gateway.auth.token = ( if (.gateway.auth.token | type) == "string" and (.gateway.auth.token | length) > 0 then .gateway.auth.token else "default_change_me" end ) ' /root/.openclaw/openclaw.json > "$tmp" && mv "$tmp" /root/.openclaw/openclaw.json fi # 5.2 provider apiKey(仅 env 有值时覆盖;否则保证为字符串) if [ -n "${OPENAI_API_KEY:-}" ]; then tmp=$(mktemp) jq --arg k "${OPENAI_API_KEY}" ' .models.providers.nvidia |= (. // {}) | .models.providers.nvidia.apiKey = $k ' /root/.openclaw/openclaw.json > "$tmp" && mv "$tmp" /root/.openclaw/openclaw.json else tmp=$(mktemp) jq ' .models.providers.nvidia |= (. // {}) | .models.providers.nvidia.apiKey = ( if (.models.providers.nvidia.apiKey | type) == "string" then .models.providers.nvidia.apiKey else "" end ) ' /root/.openclaw/openclaw.json > "$tmp" && mv "$tmp" /root/.openclaw/openclaw.json fi if [ -n "${OPENROUTER_API_KEY:-}" ]; then tmp=$(mktemp) jq --arg k "${OPENROUTER_API_KEY}" ' .models.providers.openrouterai |= (. // {}) | .models.providers.openrouterai.apiKey = $k ' /root/.openclaw/openclaw.json > "$tmp" && mv "$tmp" /root/.openclaw/openclaw.json else tmp=$(mktemp) jq ' .models.providers.openrouterai |= (. // {}) | .models.providers.openrouterai.apiKey = ( if (.models.providers.openrouterai.apiKey | type) == "string" then .models.providers.openrouterai.apiKey else "" end ) ' /root/.openclaw/openclaw.json > "$tmp" && mv "$tmp" /root/.openclaw/openclaw.json fi if [ -n "${VERCEL_AI_GATEWAY_API_KEY:-}" ]; then tmp=$(mktemp) jq --arg k "${VERCEL_AI_GATEWAY_API_KEY}" ' .models.providers.vercel |= (. // {}) | .models.providers.vercel.apiKey = $k ' /root/.openclaw/openclaw.json > "$tmp" && mv "$tmp" /root/.openclaw/openclaw.json else tmp=$(mktemp) jq ' .models.providers.vercel |= (. // {}) | .models.providers.vercel.apiKey = ( if (.models.providers.vercel.apiKey | type) == "string" then .models.providers.vercel.apiKey else "" end ) ' /root/.openclaw/openclaw.json > "$tmp" && mv "$tmp" /root/.openclaw/openclaw.json fi # 5.3 primary 路由:始终以 env 为准(env 未设则使用默认) PROVIDER_TO_SET=${PROVIDER:-"nvidia"} MODEL_TO_SET=${MODEL:-"moonshotai/kimi-k2.5"} tmp=$(mktemp) jq --arg p "$PROVIDER_TO_SET" --arg m "$MODEL_TO_SET" ' .agents |= (. // {}) | .agents.defaults |= (. // {}) | .agents.defaults.model |= (. // {}) | .agents.defaults.model.primary = ($p + "/" + $m) ' /root/.openclaw/openclaw.json > "$tmp" && mv "$tmp" /root/.openclaw/openclaw.json # 6) schema 兼容增强:运行 openclaw doctor --fix(带 timeout 防卡死) # 默认开启;如需禁用可设置 OPENCLAW_DISABLE_DOCTOR_FIX=1 DISABLE_FIX_NORM="$(printf '%s' "${OPENCLAW_DISABLE_DOCTOR_FIX-}" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" if [ "${DISABLE_FIX_NORM}" != "1" ] && [ "${DISABLE_FIX_NORM}" != "true" ] && [ "${DISABLE_FIX_NORM}" != "yes" ]; then echo "[FIX] 运行 openclaw doctor --fix(最多 20 秒,避免卡死)..." set +e timeout 20s openclaw doctor --fix >/tmp/openclaw_doctor_fix.log 2>&1 code=$? set -e if [ $code -eq 0 ]; then echo "[FIX] doctor --fix 完成" elif [ $code -eq 124 ]; then echo "[WARN] doctor --fix 超时(已跳过)" else echo "[WARN] doctor --fix 失败(代码: $code),将继续启动" fi # doctor 可能改动了少量字段,再次确保 env 关键项生效(轻量兜底) tmp=$(mktemp) jq --arg port "${GATEWAY_PORT}" ' .gateway |= (. // {}) | .gateway.port = ($port | tonumber) | .gateway.bind = ( if (.gateway.bind | type) != "string" then "lan" elif (.gateway.bind=="lan" or .gateway.bind=="loopback" or .gateway.bind=="tailnet" or .gateway.bind=="auto") then .gateway.bind else "lan" end ) ' /root/.openclaw/openclaw.json > "$tmp" && mv "$tmp" /root/.openclaw/openclaw.json fi PRIMARY_FINAL=$(jq -r '.agents.defaults.model.primary // ""' /root/.openclaw/openclaw.json 2>/dev/null || true) echo "========================================" echo " 配置摘要" echo "========================================" echo " Primary (final): ${PRIMARY_FINAL}" echo " Gateway Port: ${GATEWAY_PORT}" echo " Health Check: http://localhost:${GATEWAY_PORT}/health" echo "========================================" # 7) 启动定时备份(后台) if [ "${BACKUP_ENABLED}" = "true" ]; then echo "[START] 启动定时备份 (间隔: ${BI}秒)" ( while true; do sleep "$BI" echo "[BACKUP] 开始备份: $(date -u +%Y-%m-%dT%H:%M:%SZ)" set +e python3 /usr/local/bin/sync.py backup code=$? set -e if [ $code -eq 0 ]; then echo "[BACKUP] 备份成功" else echo "[BACKUP] 备份失败 (代码: $code)" >&2 fi done ) & else echo "[START] 定时备份已禁用(BACKUP_INTERVAL<=0)" fi echo "[START] 启动 OpenClaw Gateway (端口: ${GATEWAY_PORT})..." exec openclaw gateway run --port "${GATEWAY_PORT}" BASH RUN chmod +x /usr/local/bin/start.sh EXPOSE 7860 HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ CMD sh -c 'curl -f "http://localhost:${PORT:-7860}/health" || exit 1' CMD ["/usr/local/bin/start.sh"]