openclaw / Dockerfile
ynzheng's picture
Upload Dockerfile
b46983f verified
# 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"]