Spaces:
Sleeping
Sleeping
Upload Dockerfile
Browse files- Dockerfile +989 -0
Dockerfile
ADDED
|
@@ -0,0 +1,989 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 核心鏡像選擇
|
| 2 |
+
FROM node:24-slim
|
| 3 |
+
|
| 4 |
+
# 1. 基礎依賴補全 (合併了 layer 減少體積)
|
| 5 |
+
# 注意:code-server 通過官方安裝腳本安裝,nginx 用於反向代理
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
git openssh-client build-essential python3 python3-pip \
|
| 8 |
+
g++ make ca-certificates curl wget nginx \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
# 1.1. 安裝 code-server(瀏覽器版 VS Code,自帶 terminal)
|
| 12 |
+
# 使用官方安裝腳本,自動適配架構和最新版本
|
| 13 |
+
RUN curl -fsSL https://code-server.dev/install.sh | sh
|
| 14 |
+
|
| 15 |
+
# 2. 安裝 GitHub CLI (gh) — 使用官方 APT 倉庫,避免社區版 API 相容性問題
|
| 16 |
+
RUN mkdir -p -m 755 /etc/apt/keyrings \
|
| 17 |
+
&& out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
| 18 |
+
&& cat $out | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
|
| 19 |
+
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
| 20 |
+
&& mkdir -p -m 755 /etc/apt/sources.list.d \
|
| 21 |
+
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
| 22 |
+
| tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
| 23 |
+
&& apt-get update \
|
| 24 |
+
&& apt-get install -y --no-install-recommends gh \
|
| 25 |
+
&& rm -rf /var/lib/apt/lists/* \
|
| 26 |
+
&& gh --version
|
| 27 |
+
|
| 28 |
+
# 3. 安裝 HF 數據交互工具
|
| 29 |
+
RUN pip3 install --no-cache-dir huggingface_hub --break-system-packages
|
| 30 |
+
|
| 31 |
+
# 4. 構建環境與 Git 協議優化
|
| 32 |
+
RUN update-ca-certificates && \
|
| 33 |
+
git config --global http.sslVerify false && \
|
| 34 |
+
git config --global url."https://github.com/".insteadOf ssh://git@github.com/
|
| 35 |
+
|
| 36 |
+
# 5. OpenClaw 核心安裝
|
| 37 |
+
RUN npm install -g openclaw@latest --unsafe-perm
|
| 38 |
+
|
| 39 |
+
# 6. 安裝 ClawHub CLI
|
| 40 |
+
RUN npm install -g clawhub --unsafe-perm \
|
| 41 |
+
&& clawhub -V || true
|
| 42 |
+
|
| 43 |
+
# 6.2. 安裝 Playwright 完整包(openclaw browser 高級功能必需:snapshot/screenshot/PDF/navigate)
|
| 44 |
+
# 注意:必須安裝完整 playwright,而非 playwright-core;openclaw 在其 node_modules 內已含 playwright-core
|
| 45 |
+
RUN npm install -g playwright --unsafe-perm
|
| 46 |
+
|
| 47 |
+
# 6.3. 安裝 Chromium 及系統依賴庫(使用 Playwright 官方腳本,一步搞定 100+ 依賴)
|
| 48 |
+
RUN npx playwright install chromium --with-deps
|
| 49 |
+
|
| 50 |
+
# 7. 安裝釘釘和企業微信插件
|
| 51 |
+
RUN openclaw plugins install @dingtalk-real-ai/dingtalk-connector
|
| 52 |
+
RUN openclaw plugins install @wecom/wecom-openclaw-plugin
|
| 53 |
+
|
| 54 |
+
# 7.1. 預安裝微信個人號插件本體(腾讯官方 iLink 協議)
|
| 55 |
+
# 注意:插件本體在構建時安裝,掃碼登錄在運行時進行(二維碼會打印到 HF Logs)
|
| 56 |
+
RUN openclaw plugins install @tencent-weixin/openclaw-weixin || true
|
| 57 |
+
|
| 58 |
+
# 8. 環境變量默認值
|
| 59 |
+
ENV PORT=7860 \
|
| 60 |
+
OPENCLAW_GATEWAY_MODE=local \
|
| 61 |
+
HOME=/root \
|
| 62 |
+
PYTHONUNBUFFERED=1
|
| 63 |
+
|
| 64 |
+
# 9. 寫入 Python 同步引擎
|
| 65 |
+
RUN cat <<'EOF' > /usr/local/bin/sync.py
|
| 66 |
+
import os, sys, tarfile, shutil
|
| 67 |
+
from huggingface_hub import HfApi, hf_hub_download
|
| 68 |
+
from datetime import datetime, timedelta
|
| 69 |
+
|
| 70 |
+
api = HfApi()
|
| 71 |
+
repo_id = os.getenv("HF_DATASET")
|
| 72 |
+
token = os.getenv("HF_TOKEN")
|
| 73 |
+
|
| 74 |
+
OPENCLAW_LOCAL = "/root/.openclaw"
|
| 75 |
+
SCATTER_REMOTE_PREFIX = "openclaw/" # 散裝文件在 Dataset 中的前綴
|
| 76 |
+
|
| 77 |
+
# ── 通用工具函數 ──────────────────────────────────────────────────────────────
|
| 78 |
+
|
| 79 |
+
def _parse_skip_list(env_var):
|
| 80 |
+
"""解析逗號分隔的跳過列表,返回規範化路徑集合(支持多級子目錄)。"""
|
| 81 |
+
raw = os.getenv(env_var, "").strip()
|
| 82 |
+
if not raw:
|
| 83 |
+
return set()
|
| 84 |
+
return {s.strip().strip("/") for s in raw.split(",") if s.strip()}
|
| 85 |
+
|
| 86 |
+
def _is_skipped(rel_path, skip_set):
|
| 87 |
+
"""
|
| 88 |
+
判斷 rel_path(相對於 /root/.openclaw 的路徑)是否應跳過。
|
| 89 |
+
支持精確匹配及前綴匹配(即跳過某目錄下所有子路徑)。
|
| 90 |
+
"""
|
| 91 |
+
rel = rel_path.strip("/")
|
| 92 |
+
for skip in skip_set:
|
| 93 |
+
if rel == skip or rel.startswith(skip + "/"):
|
| 94 |
+
return True
|
| 95 |
+
return False
|
| 96 |
+
|
| 97 |
+
def _walk_local(base_dir, skip_set=None):
|
| 98 |
+
"""
|
| 99 |
+
遞歸遍歷 base_dir 下所有文件,返回 (local_abs_path, rel_to_base) 列表。
|
| 100 |
+
rel_to_base 是相對於 base_dir 的路徑(不含前導斜杠)。
|
| 101 |
+
skip_set 中的路徑是相對於 OPENCLAW_LOCAL 的路徑。
|
| 102 |
+
"""
|
| 103 |
+
results = []
|
| 104 |
+
if not os.path.isdir(base_dir):
|
| 105 |
+
return results
|
| 106 |
+
for dirpath, dirnames, filenames in os.walk(base_dir):
|
| 107 |
+
for fname in filenames:
|
| 108 |
+
abs_path = os.path.join(dirpath, fname)
|
| 109 |
+
rel_to_base = os.path.relpath(abs_path, base_dir)
|
| 110 |
+
if skip_set is not None:
|
| 111 |
+
# 計算相對於 OPENCLAW_LOCAL 的路徑用於 skip 匹配
|
| 112 |
+
rel_to_openclaw = os.path.relpath(abs_path, OPENCLAW_LOCAL)
|
| 113 |
+
if _is_skipped(rel_to_openclaw, skip_set):
|
| 114 |
+
continue
|
| 115 |
+
results.append((abs_path, rel_to_base))
|
| 116 |
+
return results
|
| 117 |
+
|
| 118 |
+
# ── restore() ────────────────────────────────────────────────────────────────
|
| 119 |
+
|
| 120 |
+
def restore():
|
| 121 |
+
if not repo_id or not token:
|
| 122 |
+
print("Skip Restore: HF_DATASET or HF_TOKEN not set")
|
| 123 |
+
return
|
| 124 |
+
try:
|
| 125 |
+
all_files = list(api.list_repo_files(repo_id=repo_id, repo_type="dataset", token=token))
|
| 126 |
+
|
| 127 |
+
# ── 1. 恢復 tar.gz 備份 ──────────────────────────────────────────────
|
| 128 |
+
# 備份範圍:/root/.openclaw 下所有文件及文件夾(保持完整目錄結構)
|
| 129 |
+
# tar 包內 arcname 直接對應 .openclaw/ 下的相對路徑,解壓目標為 /root/.openclaw/
|
| 130 |
+
now = datetime.now()
|
| 131 |
+
for i in range(5):
|
| 132 |
+
day = (now - timedelta(days=i)).strftime("%Y-%m-%d")
|
| 133 |
+
name = f"backup_{day}.tar.gz"
|
| 134 |
+
if name in all_files:
|
| 135 |
+
print(f"Downloading {name}...")
|
| 136 |
+
path = hf_hub_download(repo_id=repo_id, filename=name, repo_type="dataset", token=token)
|
| 137 |
+
os.makedirs(OPENCLAW_LOCAL, exist_ok=True)
|
| 138 |
+
with tarfile.open(path, "r:gz") as tar:
|
| 139 |
+
tar.extractall(path=OPENCLAW_LOCAL)
|
| 140 |
+
print(f"Success: Restored from {name}")
|
| 141 |
+
|
| 142 |
+
# ── RESTORE_SKIP:解壓後刪除指定條目,讓後續腳本用默認值重新生成 ──
|
| 143 |
+
# 格式:逗號分隔,填寫相對於 /root/.openclaw 的路徑
|
| 144 |
+
# 支持多級子目錄,例如:RESTORE_SKIP=agents/main/sessions,cron,extensions/foo/bar
|
| 145 |
+
# 若路徑不存在則忽略,不影響腳本繼續運行
|
| 146 |
+
# 留空或不設置 = 全部恢復(默認行為)
|
| 147 |
+
# 注意:用完後清空此變量,否則每次重啟都會跳過恢復
|
| 148 |
+
skip_set = _parse_skip_list("RESTORE_SKIP")
|
| 149 |
+
for entry in skip_set:
|
| 150 |
+
target = os.path.join(OPENCLAW_LOCAL, entry)
|
| 151 |
+
if os.path.isdir(target):
|
| 152 |
+
shutil.rmtree(target)
|
| 153 |
+
print(f"Restore skip (RESTORE_SKIP): removed dir {entry}")
|
| 154 |
+
elif os.path.isfile(target):
|
| 155 |
+
os.remove(target)
|
| 156 |
+
print(f"Restore skip (RESTORE_SKIP): removed file {entry}")
|
| 157 |
+
else:
|
| 158 |
+
print(f"Restore skip (RESTORE_SKIP): not found, skipped {entry}")
|
| 159 |
+
break
|
| 160 |
+
|
| 161 |
+
# ── 2. 恢復散裝文件 ──────────────────────────────────────────────────
|
| 162 |
+
# 備份範圍:/root/.openclaw 下所有文件及文件夾(保持完整目錄結構)
|
| 163 |
+
# Dataset 中存儲路徑:openclaw/<相對於 .openclaw 的路徑>
|
| 164 |
+
scatter_files = [f for f in all_files if f.startswith(SCATTER_REMOTE_PREFIX)]
|
| 165 |
+
if scatter_files:
|
| 166 |
+
os.makedirs(OPENCLAW_LOCAL, exist_ok=True)
|
| 167 |
+
for remote_path in scatter_files:
|
| 168 |
+
rel = remote_path[len(SCATTER_REMOTE_PREFIX):]
|
| 169 |
+
if not rel:
|
| 170 |
+
continue
|
| 171 |
+
local_path = os.path.join(OPENCLAW_LOCAL, rel)
|
| 172 |
+
print(f"Restoring file: {rel}")
|
| 173 |
+
dl = hf_hub_download(repo_id=repo_id, filename=remote_path, repo_type="dataset", token=token)
|
| 174 |
+
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
| 175 |
+
shutil.copy2(dl, local_path)
|
| 176 |
+
print(f"Scatter restore: {len(scatter_files)} file(s) restored.")
|
| 177 |
+
else:
|
| 178 |
+
print("No scatter files found in Dataset, skipping scatter restore.")
|
| 179 |
+
|
| 180 |
+
except Exception as e:
|
| 181 |
+
print(f"Restore Error: {e}")
|
| 182 |
+
|
| 183 |
+
# ── restore_workspace() ───────────────────────────────────────────────────────
|
| 184 |
+
|
| 185 |
+
def restore_workspace():
|
| 186 |
+
"""
|
| 187 |
+
從 HuggingFace Dataset 的 openclaw/ 前綴下拉取最新文件到本地 /root/.openclaw/。
|
| 188 |
+
與 restore() 的區別:
|
| 189 |
+
- 不處理 tar.gz 備份
|
| 190 |
+
- 專門用於"用戶在 Dataset 網頁上編輯了文件後,讓容器立刻感知更新"的場景
|
| 191 |
+
- 由後台定時循環每 30 分鐘自動調用一次
|
| 192 |
+
- 也可以通過釘釘發指令手動觸發:python3 /usr/local/bin/sync.py restore_workspace
|
| 193 |
+
衝突保護邏輯:
|
| 194 |
+
- Dataset 上的版本始終優先(網頁手動編輯的版本會覆蓋本地)
|
| 195 |
+
- 這與 backup() 的邏輯相反:backup() 是本地新則上傳,restore_workspace() 是遠端新則下載
|
| 196 |
+
- 若本地文件比遠端更新(本地有未備份的修改),跳過下載,保留本地版本
|
| 197 |
+
"""
|
| 198 |
+
if not repo_id or not token:
|
| 199 |
+
print("Skip restore_workspace: HF_DATASET or HF_TOKEN not set")
|
| 200 |
+
return
|
| 201 |
+
try:
|
| 202 |
+
all_files = list(api.list_repo_files(
|
| 203 |
+
repo_id=repo_id, repo_type="dataset", token=token
|
| 204 |
+
))
|
| 205 |
+
scatter_files = [f for f in all_files if f.startswith(SCATTER_REMOTE_PREFIX)]
|
| 206 |
+
if not scatter_files:
|
| 207 |
+
print("restore_workspace: No scatter files found in Dataset, skipping.")
|
| 208 |
+
return
|
| 209 |
+
os.makedirs(OPENCLAW_LOCAL, exist_ok=True)
|
| 210 |
+
updated = 0
|
| 211 |
+
skipped = 0
|
| 212 |
+
for remote_path in scatter_files:
|
| 213 |
+
rel = remote_path[len(SCATTER_REMOTE_PREFIX):]
|
| 214 |
+
if not rel:
|
| 215 |
+
continue
|
| 216 |
+
local_path = os.path.join(OPENCLAW_LOCAL, rel)
|
| 217 |
+
try:
|
| 218 |
+
remote_info = api.list_repo_tree(
|
| 219 |
+
repo_id=repo_id, repo_type="dataset", token=token,
|
| 220 |
+
path_in_repo=SCATTER_REMOTE_PREFIX.rstrip("/"), recursive=True
|
| 221 |
+
)
|
| 222 |
+
remote_mtime = None
|
| 223 |
+
for item in remote_info:
|
| 224 |
+
if hasattr(item, "path") and item.path == remote_path:
|
| 225 |
+
if item.last_commit and item.last_commit.date:
|
| 226 |
+
remote_mtime = item.last_commit.date.timestamp()
|
| 227 |
+
break
|
| 228 |
+
if remote_mtime and os.path.exists(local_path):
|
| 229 |
+
local_mtime = os.path.getmtime(local_path)
|
| 230 |
+
if local_mtime >= remote_mtime:
|
| 231 |
+
skipped += 1
|
| 232 |
+
continue
|
| 233 |
+
except Exception:
|
| 234 |
+
pass
|
| 235 |
+
dl = hf_hub_download(
|
| 236 |
+
repo_id=repo_id, filename=remote_path,
|
| 237 |
+
repo_type="dataset", token=token
|
| 238 |
+
)
|
| 239 |
+
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
| 240 |
+
shutil.copy2(dl, local_path)
|
| 241 |
+
print(f"restore_workspace: updated {rel}")
|
| 242 |
+
updated += 1
|
| 243 |
+
print(f"restore_workspace: {updated} updated, {skipped} already up-to-date.")
|
| 244 |
+
except Exception as e:
|
| 245 |
+
print(f"restore_workspace Error: {e}")
|
| 246 |
+
|
| 247 |
+
# ── backup() ──────────────────────────────────────────────────────────────────
|
| 248 |
+
|
| 249 |
+
def backup():
|
| 250 |
+
if not repo_id or not token:
|
| 251 |
+
print("Skip Backup: HF_DATASET or HF_TOKEN not set")
|
| 252 |
+
return
|
| 253 |
+
|
| 254 |
+
# ── 1. tar.gz 打包備份 ───────────────────────────────────────────────────
|
| 255 |
+
# 備份範圍:/root/.openclaw 下所有文件及文件夾(完整目錄結構)
|
| 256 |
+
# tar 包解壓目標為 /root/.openclaw/,arcname 對應其內部相對路徑
|
| 257 |
+
#
|
| 258 |
+
# BACKUP_TAR_SKIP:tar.gz 備份時跳過的文件/目錄(相對於 /root/.openclaw)
|
| 259 |
+
# 格式:逗號分隔,支持多級子目錄
|
| 260 |
+
# 例如:BACKUP_TAR_SKIP=agents/main/sessions,cron,extensions/foo/bar
|
| 261 |
+
# 留空或不設置 = 備份全部
|
| 262 |
+
try:
|
| 263 |
+
tar_skip_set = _parse_skip_list("BACKUP_TAR_SKIP")
|
| 264 |
+
day = datetime.now().strftime("%Y-%m-%d")
|
| 265 |
+
name = f"backup_{day}.tar.gz"
|
| 266 |
+
with tarfile.open(name, "w:gz") as tar:
|
| 267 |
+
for abs_path, rel_to_base in _walk_local(OPENCLAW_LOCAL, skip_set=tar_skip_set):
|
| 268 |
+
arcname = rel_to_base # 相對於 .openclaw/
|
| 269 |
+
tar.add(abs_path, arcname=arcname)
|
| 270 |
+
api.upload_file(path_or_fileobj=name, path_in_repo=name, repo_id=repo_id, repo_type="dataset", token=token)
|
| 271 |
+
print(f"Backup {name} Success.")
|
| 272 |
+
except Exception as e:
|
| 273 |
+
print(f"Backup tar.gz Error: {e}")
|
| 274 |
+
|
| 275 |
+
# ── 2. 散裝文件備份(逐個上傳,跳過 Dataset 上更新的文件)────────────────
|
| 276 |
+
# 備份範圍:/root/.openclaw 下所有文件及文件夾(保持完整目錄結構)
|
| 277 |
+
# Dataset 中存儲路徑:openclaw/<相對於 .openclaw 的路徑>
|
| 278 |
+
#
|
| 279 |
+
# BACKUP_SCATTER_SKIP:散裝備份時跳過的文件/目錄(相對於 /root/.openclaw)
|
| 280 |
+
# 格式:逗號分隔,支持多級子目錄
|
| 281 |
+
# 例如:BACKUP_SCATTER_SKIP=agents/main/sessions,cron,workspace/tmp
|
| 282 |
+
# 留空或不設置 = 備份全部
|
| 283 |
+
#
|
| 284 |
+
# 核心改動:原來逐個 upload_file 每個文件產生1個commit,
|
| 285 |
+
# 很快觸發 HF 免費帳號 128 commits/小時限制。
|
| 286 |
+
# 改為:把需要上傳的文件複製到臨時目錄,再用 upload_folder 一次性
|
| 287 |
+
# 合成單個 commit 上傳,徹底解決 429 rate limit 問題。
|
| 288 |
+
try:
|
| 289 |
+
scatter_skip_set = _parse_skip_list("BACKUP_SCATTER_SKIP")
|
| 290 |
+
|
| 291 |
+
# 獲取 Dataset 上已有文件的最後修改時間(用於跳過 Dataset 更新的文件)
|
| 292 |
+
remote_mtimes = {}
|
| 293 |
+
try:
|
| 294 |
+
repo_info = api.list_repo_tree(
|
| 295 |
+
repo_id=repo_id, repo_type="dataset", token=token,
|
| 296 |
+
path_in_repo=SCATTER_REMOTE_PREFIX.rstrip("/"), recursive=True
|
| 297 |
+
)
|
| 298 |
+
for item in repo_info:
|
| 299 |
+
if hasattr(item, "path") and hasattr(item, "last_commit"):
|
| 300 |
+
rel = item.path[len(SCATTER_REMOTE_PREFIX):]
|
| 301 |
+
if item.last_commit and item.last_commit.date:
|
| 302 |
+
remote_mtimes[rel] = item.last_commit.date.timestamp()
|
| 303 |
+
except Exception:
|
| 304 |
+
pass
|
| 305 |
+
|
| 306 |
+
# 把需要上傳的文件複製到臨時目錄,保持目錄結構,再一次性 upload_folder
|
| 307 |
+
import tempfile
|
| 308 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 309 |
+
uploaded = 0
|
| 310 |
+
skipped = 0
|
| 311 |
+
for abs_path, rel_to_base in _walk_local(OPENCLAW_LOCAL, skip_set=scatter_skip_set):
|
| 312 |
+
local_mtime = os.path.getmtime(abs_path)
|
| 313 |
+
remote_mtime = remote_mtimes.get(rel_to_base)
|
| 314 |
+
if remote_mtime and remote_mtime > local_mtime:
|
| 315 |
+
print(f"Scatter skip (Dataset newer): {rel_to_base}")
|
| 316 |
+
skipped += 1
|
| 317 |
+
continue
|
| 318 |
+
dst = os.path.join(tmpdir, rel_to_base)
|
| 319 |
+
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
| 320 |
+
shutil.copy2(abs_path, dst)
|
| 321 |
+
uploaded += 1
|
| 322 |
+
|
| 323 |
+
if uploaded > 0:
|
| 324 |
+
# upload_folder 將 tmpdir 下所有文件合成單個 commit 上傳
|
| 325 |
+
# path_in_repo 指定遠端存放前綴(openclaw/)
|
| 326 |
+
api.upload_folder(
|
| 327 |
+
folder_path=tmpdir,
|
| 328 |
+
path_in_repo=SCATTER_REMOTE_PREFIX.rstrip("/"),
|
| 329 |
+
repo_id=repo_id,
|
| 330 |
+
repo_type="dataset",
|
| 331 |
+
token=token,
|
| 332 |
+
commit_message=f"Scatter backup {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
| 333 |
+
)
|
| 334 |
+
print(f"Scatter backup: {uploaded} file(s) uploaded in single commit, {skipped} skipped (Dataset newer).")
|
| 335 |
+
else:
|
| 336 |
+
print(f"Scatter backup: nothing to upload ({skipped} skipped, Dataset newer).")
|
| 337 |
+
except Exception as e:
|
| 338 |
+
print(f"Scatter Backup Error: {e}")
|
| 339 |
+
|
| 340 |
+
if __name__ == "__main__":
|
| 341 |
+
if len(sys.argv) > 1 and sys.argv[1] == "backup":
|
| 342 |
+
backup()
|
| 343 |
+
elif len(sys.argv) > 1 and sys.argv[1] == "restore_workspace":
|
| 344 |
+
restore_workspace()
|
| 345 |
+
else:
|
| 346 |
+
restore()
|
| 347 |
+
EOF
|
| 348 |
+
|
| 349 |
+
# 10. 寫入 providers JSON 生成腳本
|
| 350 |
+
RUN cat <<'EOF' > /usr/local/bin/build_providers.py
|
| 351 |
+
import os, json
|
| 352 |
+
|
| 353 |
+
def get(name):
|
| 354 |
+
return os.environ.get(name, "")
|
| 355 |
+
|
| 356 |
+
providers = {}
|
| 357 |
+
for i in range(1, 6):
|
| 358 |
+
pname = get(f"provide{i}")
|
| 359 |
+
baseurl = get(f"baseUrl{i}")
|
| 360 |
+
apikey = get(f"apiKey{i}")
|
| 361 |
+
api = get(f"provide{i}_api1")
|
| 362 |
+
if not pname or not baseurl or not api:
|
| 363 |
+
continue
|
| 364 |
+
models = []
|
| 365 |
+
for m in range(1, 6):
|
| 366 |
+
mid = get(f"provide{i}_models_id{m}")
|
| 367 |
+
mname = get(f"provide{i}_models_name{m}")
|
| 368 |
+
inp = get(f"provide{i}_models_input{m}")
|
| 369 |
+
if not mid:
|
| 370 |
+
continue
|
| 371 |
+
input_val = ["text", "image"] if "image" in inp.lower() else ["text"]
|
| 372 |
+
models.append({
|
| 373 |
+
"id": mid,
|
| 374 |
+
"name": mname,
|
| 375 |
+
"input": input_val,
|
| 376 |
+
"reasoning": False,
|
| 377 |
+
"contextWindow": 2000000,
|
| 378 |
+
"maxTokens": 2000000,
|
| 379 |
+
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}
|
| 380 |
+
})
|
| 381 |
+
if not models:
|
| 382 |
+
continue
|
| 383 |
+
providers[pname] = {
|
| 384 |
+
"baseUrl": baseurl,
|
| 385 |
+
"apiKey": apikey,
|
| 386 |
+
"api": api,
|
| 387 |
+
"models": models
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
entries = []
|
| 391 |
+
for k, v in providers.items():
|
| 392 |
+
block = json.dumps({k: v}, indent=4, ensure_ascii=False)
|
| 393 |
+
inner = block.strip()[1:-1].strip()
|
| 394 |
+
indented = "\n".join(" " + line for line in inner.split("\n"))
|
| 395 |
+
entries.append(indented)
|
| 396 |
+
|
| 397 |
+
print(",\n".join(entries))
|
| 398 |
+
EOF
|
| 399 |
+
|
| 400 |
+
# 11. 寫入啟動控制邏輯
|
| 401 |
+
RUN cat <<'EOF' > /usr/local/bin/start-openclaw
|
| 402 |
+
#!/bin/bash
|
| 403 |
+
set -e
|
| 404 |
+
|
| 405 |
+
mkdir -p /root/.openclaw/sessions
|
| 406 |
+
|
| 407 |
+
# 執行恢復邏輯
|
| 408 |
+
python3 /usr/local/bin/sync.py restore
|
| 409 |
+
|
| 410 |
+
# provider1 model2~5 fallback 到自身 model1
|
| 411 |
+
provide1_models_id2="${provide1_models_id2:-$provide1_models_id1}"
|
| 412 |
+
provide1_models_name2="${provide1_models_name2:-$provide1_models_name1}"
|
| 413 |
+
provide1_models_api2="${provide1_models_api2:-$provide1_models_api1}"
|
| 414 |
+
provide1_models_input2="${provide1_models_input2:-$provide1_models_input1}"
|
| 415 |
+
provide1_models_id3="${provide1_models_id3:-$provide1_models_id1}"
|
| 416 |
+
provide1_models_name3="${provide1_models_name3:-$provide1_models_name1}"
|
| 417 |
+
provide1_models_api3="${provide1_models_api3:-$provide1_models_api1}"
|
| 418 |
+
provide1_models_input3="${provide1_models_input3:-$provide1_models_input1}"
|
| 419 |
+
provide1_models_id4="${provide1_models_id4:-$provide1_models_id1}"
|
| 420 |
+
provide1_models_name4="${provide1_models_name4:-$provide1_models_name1}"
|
| 421 |
+
provide1_models_api4="${provide1_models_api4:-$provide1_models_api1}"
|
| 422 |
+
provide1_models_input4="${provide1_models_input4:-$provide1_models_input1}"
|
| 423 |
+
provide1_models_id5="${provide1_models_id5:-$provide1_models_id1}"
|
| 424 |
+
provide1_models_name5="${provide1_models_name5:-$provide1_models_name1}"
|
| 425 |
+
provide1_models_api5="${provide1_models_api5:-$provide1_models_api1}"
|
| 426 |
+
provide1_models_input5="${provide1_models_input5:-$provide1_models_input1}"
|
| 427 |
+
|
| 428 |
+
# provider2 model2~5 fallback 到自身 model1
|
| 429 |
+
provide2_models_id2="${provide2_models_id2:-$provide2_models_id1}"
|
| 430 |
+
provide2_models_name2="${provide2_models_name2:-$provide2_models_name1}"
|
| 431 |
+
provide2_models_api2="${provide2_models_api2:-$provide2_models_api1}"
|
| 432 |
+
provide2_models_input2="${provide2_models_input2:-$provide2_models_input1}"
|
| 433 |
+
provide2_models_id3="${provide2_models_id3:-$provide2_models_id1}"
|
| 434 |
+
provide2_models_name3="${provide2_models_name3:-$provide2_models_name1}"
|
| 435 |
+
provide2_models_api3="${provide2_models_api3:-$provide2_models_api1}"
|
| 436 |
+
provide2_models_input3="${provide2_models_input3:-$provide2_models_input1}"
|
| 437 |
+
provide2_models_id4="${provide2_models_id4:-$provide2_models_id1}"
|
| 438 |
+
provide2_models_name4="${provide2_models_name4:-$provide2_models_name1}"
|
| 439 |
+
provide2_models_api4="${provide2_models_api4:-$provide2_models_api1}"
|
| 440 |
+
provide2_models_input4="${provide2_models_input4:-$provide2_models_input1}"
|
| 441 |
+
provide2_models_id5="${provide2_models_id5:-$provide2_models_id1}"
|
| 442 |
+
provide2_models_name5="${provide2_models_name5:-$provide2_models_name1}"
|
| 443 |
+
provide2_models_api5="${provide2_models_api5:-$provide2_models_api1}"
|
| 444 |
+
provide2_models_input5="${provide2_models_input5:-$provide2_models_input1}"
|
| 445 |
+
|
| 446 |
+
# provider3 model2~5 fallback 到自身 model1
|
| 447 |
+
provide3_models_id2="${provide3_models_id2:-$provide3_models_id1}"
|
| 448 |
+
provide3_models_name2="${provide3_models_name2:-$provide3_models_name1}"
|
| 449 |
+
provide3_models_api2="${provide3_models_api2:-$provide3_models_api1}"
|
| 450 |
+
provide3_models_input2="${provide3_models_input2:-$provide3_models_input1}"
|
| 451 |
+
provide3_models_id3="${provide3_models_id3:-$provide3_models_id1}"
|
| 452 |
+
provide3_models_name3="${provide3_models_name3:-$provide3_models_name1}"
|
| 453 |
+
provide3_models_api3="${provide3_models_api3:-$provide3_models_api1}"
|
| 454 |
+
provide3_models_input3="${provide3_models_input3:-$provide3_models_input1}"
|
| 455 |
+
provide3_models_id4="${provide3_models_id4:-$provide3_models_id1}"
|
| 456 |
+
provide3_models_name4="${provide3_models_name4:-$provide3_models_name1}"
|
| 457 |
+
provide3_models_api4="${provide3_models_api4:-$provide3_models_api1}"
|
| 458 |
+
provide3_models_input4="${provide3_models_input4:-$provide3_models_input1}"
|
| 459 |
+
provide3_models_id5="${provide3_models_id5:-$provide3_models_id1}"
|
| 460 |
+
provide3_models_name5="${provide3_models_name5:-$provide3_models_name1}"
|
| 461 |
+
provide3_models_api5="${provide3_models_api5:-$provide3_models_api1}"
|
| 462 |
+
provide3_models_input5="${provide3_models_input5:-$provide3_models_input1}"
|
| 463 |
+
|
| 464 |
+
# provider4 model2~5 fallback 到自身 model1
|
| 465 |
+
provide4_models_id2="${provide4_models_id2:-$provide4_models_id1}"
|
| 466 |
+
provide4_models_name2="${provide4_models_name2:-$provide4_models_name1}"
|
| 467 |
+
provide4_models_api2="${provide4_models_api2:-$provide4_models_api1}"
|
| 468 |
+
provide4_models_input2="${provide4_models_input2:-$provide4_models_input1}"
|
| 469 |
+
provide4_models_id3="${provide4_models_id3:-$provide4_models_id1}"
|
| 470 |
+
provide4_models_name3="${provide4_models_name3:-$provide4_models_name1}"
|
| 471 |
+
provide4_models_api3="${provide4_models_api3:-$provide4_models_api1}"
|
| 472 |
+
provide4_models_input3="${provide4_models_input3:-$provide4_models_input1}"
|
| 473 |
+
provide4_models_id4="${provide4_models_id4:-$provide4_models_id1}"
|
| 474 |
+
provide4_models_name4="${provide4_models_name4:-$provide4_models_name1}"
|
| 475 |
+
provide4_models_api4="${provide4_models_api4:-$provide4_models_api1}"
|
| 476 |
+
provide4_models_input4="${provide4_models_input4:-$provide4_models_input1}"
|
| 477 |
+
provide4_models_id5="${provide4_models_id5:-$provide4_models_id1}"
|
| 478 |
+
provide4_models_name5="${provide4_models_name5:-$provide4_models_name1}"
|
| 479 |
+
provide4_models_api5="${provide4_models_api5:-$provide4_models_api1}"
|
| 480 |
+
provide4_models_input5="${provide4_models_input5:-$provide4_models_input1}"
|
| 481 |
+
|
| 482 |
+
# provider5 model2~5 fallback 到自身 model1
|
| 483 |
+
provide5_models_id2="${provide5_models_id2:-$provide5_models_id1}"
|
| 484 |
+
provide5_models_name2="${provide5_models_name2:-$provide5_models_name1}"
|
| 485 |
+
provide5_models_api2="${provide5_models_api2:-$provide5_models_api1}"
|
| 486 |
+
provide5_models_input2="${provide5_models_input2:-$provide5_models_input1}"
|
| 487 |
+
provide5_models_id3="${provide5_models_id3:-$provide5_models_id1}"
|
| 488 |
+
provide5_models_name3="${provide5_models_name3:-$provide5_models_name1}"
|
| 489 |
+
provide5_models_api3="${provide5_models_api3:-$provide5_models_api1}"
|
| 490 |
+
provide5_models_input3="${provide5_models_input3:-$provide5_models_input1}"
|
| 491 |
+
provide5_models_id4="${provide5_models_id4:-$provide5_models_id1}"
|
| 492 |
+
provide5_models_name4="${provide5_models_name4:-$provide5_models_name1}"
|
| 493 |
+
provide5_models_api4="${provide5_models_api4:-$provide5_models_api1}"
|
| 494 |
+
provide5_models_input4="${provide5_models_input4:-$provide5_models_input1}"
|
| 495 |
+
provide5_models_id5="${provide5_models_id5:-$provide5_models_id1}"
|
| 496 |
+
provide5_models_name5="${provide5_models_name5:-$provide5_models_name1}"
|
| 497 |
+
provide5_models_api5="${provide5_models_api5:-$provide5_models_api1}"
|
| 498 |
+
provide5_models_input5="${provide5_models_input5:-$provide5_models_input1}"
|
| 499 |
+
|
| 500 |
+
# google model2~5 fallback
|
| 501 |
+
google_model2="${google_model2:-$google_model1}"
|
| 502 |
+
google_model3="${google_model3:-$google_model1}"
|
| 503 |
+
google_model4="${google_model4:-$google_model1}"
|
| 504 |
+
google_model5="${google_model5:-$google_model1}"
|
| 505 |
+
|
| 506 |
+
# primary_model / primary_imageModel fallback
|
| 507 |
+
primary_model="${primary_model:-${provide1}/${provide1_models_id1}}"
|
| 508 |
+
primary_imageModel="${primary_imageModel:-${provide1}/${provide1_models_id1}}"
|
| 509 |
+
|
| 510 |
+
PROVIDERS_JSON=$(python3 /usr/local/bin/build_providers.py)
|
| 511 |
+
|
| 512 |
+
# ── 動態 Channel 配置:DINGTALK 變量未設置時只寫入 disabled 配置,避免健康檢查拋出未捕獲異常崩潰進程 ──
|
| 513 |
+
if [ -n "${DINGTALK_clientId}" ] && [ -n "${DINGTALK_clientSecret}" ]; then
|
| 514 |
+
DINGTALK_CHANNEL_JSON='"dingtalk-connector": {
|
| 515 |
+
"enabled": true,
|
| 516 |
+
"clientId": "${DINGTALK_clientId}",
|
| 517 |
+
"clientSecret": "${DINGTALK_clientSecret}"
|
| 518 |
+
},'
|
| 519 |
+
else
|
| 520 |
+
DINGTALK_CHANNEL_JSON='"dingtalk-connector": {
|
| 521 |
+
"enabled": false
|
| 522 |
+
},'
|
| 523 |
+
fi
|
| 524 |
+
|
| 525 |
+
cat > /root/.openclaw/openclaw.json <<EOT
|
| 526 |
+
{
|
| 527 |
+
"models": {
|
| 528 |
+
"mode": "merge",
|
| 529 |
+
"providers": {
|
| 530 |
+
${PROVIDERS_JSON}
|
| 531 |
+
}
|
| 532 |
+
},
|
| 533 |
+
"agents": {
|
| 534 |
+
"defaults": {
|
| 535 |
+
"model": {
|
| 536 |
+
"primary": "${primary_model}"
|
| 537 |
+
},
|
| 538 |
+
"imageModel": {
|
| 539 |
+
"primary": "${primary_imageModel}"
|
| 540 |
+
},
|
| 541 |
+
"models": {
|
| 542 |
+
"${provide1}/${provide1_models_id1}": {},
|
| 543 |
+
"${provide1}/${provide1_models_id2}": {},
|
| 544 |
+
"${provide1}/${provide1_models_id3}": {},
|
| 545 |
+
"${provide1}/${provide1_models_id4}": {},
|
| 546 |
+
"${provide1}/${provide1_models_id5}": {},
|
| 547 |
+
"${provide2}/${provide2_models_id1}": {},
|
| 548 |
+
"${provide2}/${provide2_models_id2}": {},
|
| 549 |
+
"${provide2}/${provide2_models_id3}": {},
|
| 550 |
+
"${provide2}/${provide2_models_id4}": {},
|
| 551 |
+
"${provide2}/${provide2_models_id5}": {},
|
| 552 |
+
"${provide3}/${provide3_models_id1}": {},
|
| 553 |
+
"${provide3}/${provide3_models_id2}": {},
|
| 554 |
+
"${provide3}/${provide3_models_id3}": {},
|
| 555 |
+
"${provide3}/${provide3_models_id4}": {},
|
| 556 |
+
"${provide3}/${provide3_models_id5}": {},
|
| 557 |
+
"${provide4}/${provide4_models_id1}": {},
|
| 558 |
+
"${provide4}/${provide4_models_id2}": {},
|
| 559 |
+
"${provide4}/${provide4_models_id3}": {},
|
| 560 |
+
"${provide4}/${provide4_models_id4}": {},
|
| 561 |
+
"${provide4}/${provide4_models_id5}": {},
|
| 562 |
+
"${provide5}/${provide5_models_id1}": {},
|
| 563 |
+
"${provide5}/${provide5_models_id2}": {},
|
| 564 |
+
"${provide5}/${provide5_models_id3}": {},
|
| 565 |
+
"${provide5}/${provide5_models_id4}": {},
|
| 566 |
+
"${provide5}/${provide5_models_id5}": {},
|
| 567 |
+
"google/${google_model1}": {},
|
| 568 |
+
"google/${google_model2}": {},
|
| 569 |
+
"google/${google_model3}": {},
|
| 570 |
+
"google/${google_model4}": {},
|
| 571 |
+
"google/${google_model5}": {}
|
| 572 |
+
}
|
| 573 |
+
}
|
| 574 |
+
},
|
| 575 |
+
"gateway": {
|
| 576 |
+
"mode": "local",
|
| 577 |
+
"bind": "lan",
|
| 578 |
+
"port": ${PORT},
|
| 579 |
+
"trustedProxies": [
|
| 580 |
+
"0.0.0.0/0",
|
| 581 |
+
"10.0.0.0/8",
|
| 582 |
+
"172.16.0.0/12",
|
| 583 |
+
"192.168.0.0/16"
|
| 584 |
+
],
|
| 585 |
+
"auth": {
|
| 586 |
+
"mode": "token",
|
| 587 |
+
"token": "${OPENCLAW_PASSWORD}"
|
| 588 |
+
},
|
| 589 |
+
"controlUi": {
|
| 590 |
+
"allowInsecureAuth": true,
|
| 591 |
+
"dangerouslyAllowHostHeaderOriginFallback": true
|
| 592 |
+
}
|
| 593 |
+
},
|
| 594 |
+
"cron": {
|
| 595 |
+
"enabled": true,
|
| 596 |
+
"store": "/root/.openclaw/cron/jobs.json",
|
| 597 |
+
"maxConcurrentRuns": 1
|
| 598 |
+
},
|
| 599 |
+
"plugins": {
|
| 600 |
+
"allow": ["dingtalk-connector", "wecom-openclaw-plugin", "openclaw-weixin"],
|
| 601 |
+
"entries": {
|
| 602 |
+
"dingtalk-connector": {
|
| 603 |
+
"enabled": true
|
| 604 |
+
},
|
| 605 |
+
"wecom-openclaw-plugin": {
|
| 606 |
+
"enabled": true
|
| 607 |
+
},
|
| 608 |
+
"openclaw-weixin": {
|
| 609 |
+
"enabled": true
|
| 610 |
+
}
|
| 611 |
+
}
|
| 612 |
+
},
|
| 613 |
+
"skills": {
|
| 614 |
+
"entries": {
|
| 615 |
+
"liang-tavily-search": {
|
| 616 |
+
"enabled": true,
|
| 617 |
+
"apiKey": "${TAVILY_API_KEY}"
|
| 618 |
+
}
|
| 619 |
+
}
|
| 620 |
+
},
|
| 621 |
+
"channels": {
|
| 622 |
+
${DINGTALK_CHANNEL_JSON}
|
| 623 |
+
"wecom": {
|
| 624 |
+
"enabled": true,
|
| 625 |
+
"botId": "${WECOM_BOT_ID}",
|
| 626 |
+
"secret": "${WECOM_SECRET}",
|
| 627 |
+
"dmPolicy": "open",
|
| 628 |
+
"groupPolicy": "open",
|
| 629 |
+
"messageType": "markdown",
|
| 630 |
+
"debug": false,
|
| 631 |
+
"allowFrom": ["*"]
|
| 632 |
+
}
|
| 633 |
+
},
|
| 634 |
+
"browser": {
|
| 635 |
+
"enabled": true,
|
| 636 |
+
"headless": true,
|
| 637 |
+
"noSandbox": true,
|
| 638 |
+
"defaultProfile": "openclaw",
|
| 639 |
+
"executablePath": "/root/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome",
|
| 640 |
+
"ssrfPolicy": {
|
| 641 |
+
"dangerouslyAllowPrivateNetwork": true
|
| 642 |
+
}
|
| 643 |
+
}}
|
| 644 |
+
EOT
|
| 645 |
+
|
| 646 |
+
# 生成 cron jobs 定義文件(僅在不存在時寫入,避免覆蓋用戶在運行時添加的 job)
|
| 647 |
+
# KEEP_ALIVE_MODEL:指定 keep-alive job 使用的模型,格式為 provider/model-id
|
| 648 |
+
# 例如:KEEP_ALIVE_MODEL=google/gemini-flash-lite
|
| 649 |
+
# 留空或不設置則由 openclaw 默認模型執行
|
| 650 |
+
mkdir -p /root/.openclaw/cron
|
| 651 |
+
if [ ! -f /root/.openclaw/cron/jobs.json ]; then
|
| 652 |
+
python3 - << 'PYEOF'
|
| 653 |
+
import os, json
|
| 654 |
+
|
| 655 |
+
enable_keep_alive = os.environ.get("ENABLE_KEEP_ALIVE", "false").strip().lower() in ("true", "1", "yes")
|
| 656 |
+
|
| 657 |
+
if enable_keep_alive:
|
| 658 |
+
payload = {
|
| 659 |
+
"kind": "agentTurn",
|
| 660 |
+
"message": "curl -s -o /dev/null -w '%{http_code}' -X OPTIONS https://heiyu-mo-openclaw.hf.space/",
|
| 661 |
+
"lightContext": True
|
| 662 |
+
}
|
| 663 |
+
keep_alive_model = os.environ.get("KEEP_ALIVE_MODEL", "").strip()
|
| 664 |
+
if keep_alive_model:
|
| 665 |
+
payload["model"] = keep_alive_model
|
| 666 |
+
|
| 667 |
+
jobs = {
|
| 668 |
+
"version": 1,
|
| 669 |
+
"jobs": [{
|
| 670 |
+
"id": "0f174587-f3cd-4ea6-90ef-887c845fdea0",
|
| 671 |
+
"name": "keep-alive",
|
| 672 |
+
"enabled": True,
|
| 673 |
+
"createdAtMs": 1772522542926,
|
| 674 |
+
"updatedAtMs": 1772594582735,
|
| 675 |
+
"schedule": {"kind": "every", "everyMs": 3600000, "anchorMs": 1772522542926},
|
| 676 |
+
"sessionTarget": "isolated",
|
| 677 |
+
"wakeMode": "now",
|
| 678 |
+
"payload": payload,
|
| 679 |
+
"delivery": {"mode": "none", "channel": "last"},
|
| 680 |
+
"state": {
|
| 681 |
+
"nextRunAtMs": 1772598143029,
|
| 682 |
+
"lastRunAtMs": 1772594543029,
|
| 683 |
+
"lastRunStatus": "ok",
|
| 684 |
+
"lastStatus": "ok",
|
| 685 |
+
"lastDurationMs": 39706,
|
| 686 |
+
"lastDelivered": False,
|
| 687 |
+
"deliveryStatus": "not-delivered",
|
| 688 |
+
"consecutiveErrors": 0
|
| 689 |
+
}
|
| 690 |
+
}]
|
| 691 |
+
}
|
| 692 |
+
model_info = f"model={keep_alive_model}" if keep_alive_model else "model=default"
|
| 693 |
+
print(f"Cron jobs.json initialized. keep-alive enabled, {model_info}")
|
| 694 |
+
else:
|
| 695 |
+
jobs = {"version": 1, "jobs": []}
|
| 696 |
+
print("Cron jobs.json initialized. keep-alive disabled")
|
| 697 |
+
|
| 698 |
+
with open("/root/.openclaw/cron/jobs.json", "w") as f:
|
| 699 |
+
json.dump(jobs, f, separators=(",", ":"))
|
| 700 |
+
PYEOF
|
| 701 |
+
else
|
| 702 |
+
echo "Cron jobs.json already exists, skipping init."
|
| 703 |
+
fi
|
| 704 |
+
|
| 705 |
+
# 增量備份循環(每 30 分鐘後台運行)
|
| 706 |
+
(while true; do sleep 1800; python3 /usr/local/bin/sync.py backup; done) &
|
| 707 |
+
|
| 708 |
+
# Workspace 同步循環(每 30 分鐘後台運行)
|
| 709 |
+
(while true; do sleep 1800; python3 /usr/local/bin/sync.py restore_workspace; done) &
|
| 710 |
+
|
| 711 |
+
# 把 HF Space Secrets 裡的 GEMINI key 寫入 auth-profiles.json
|
| 712 |
+
mkdir -p /root/.openclaw/agents/main/agent
|
| 713 |
+
python3 - << 'PYEOF'
|
| 714 |
+
import os, json
|
| 715 |
+
|
| 716 |
+
auth_file = "/root/.openclaw/agents/main/agent/auth-profiles.json"
|
| 717 |
+
|
| 718 |
+
if os.path.exists(auth_file):
|
| 719 |
+
with open(auth_file) as f:
|
| 720 |
+
data = json.load(f)
|
| 721 |
+
for pid, profile in data.get("profiles", {}).items():
|
| 722 |
+
if profile.get("type") == "api_key" and "token" in profile and "key" not in profile:
|
| 723 |
+
profile["key"] = profile.pop("token")
|
| 724 |
+
else:
|
| 725 |
+
data = {"profiles": {}, "lastGood": {}}
|
| 726 |
+
|
| 727 |
+
key_names = [
|
| 728 |
+
("GEMINI_API_KEY_1", "google:key1"),
|
| 729 |
+
("GEMINI_API_KEY_2", "google:key2"),
|
| 730 |
+
("GEMINI_API_KEY_3", "google:key3"),
|
| 731 |
+
("GEMINI_API_KEY_4", "google:key4"),
|
| 732 |
+
("GEMINI_API_KEY_5", "google:key5"),
|
| 733 |
+
]
|
| 734 |
+
first_profile_id = None
|
| 735 |
+
for env_name, profile_id in key_names:
|
| 736 |
+
key_val = os.environ.get(env_name, "").strip()
|
| 737 |
+
if not key_val:
|
| 738 |
+
continue
|
| 739 |
+
data["profiles"][profile_id] = {
|
| 740 |
+
"type": "api_key",
|
| 741 |
+
"provider": "google",
|
| 742 |
+
"key": key_val
|
| 743 |
+
}
|
| 744 |
+
if first_profile_id is None:
|
| 745 |
+
first_profile_id = profile_id
|
| 746 |
+
|
| 747 |
+
if first_profile_id:
|
| 748 |
+
data["lastGood"]["google"] = first_profile_id
|
| 749 |
+
|
| 750 |
+
with open(auth_file, "w") as f:
|
| 751 |
+
json.dump(data, f, indent=2)
|
| 752 |
+
|
| 753 |
+
print(f"auth-profiles.json written: {len([k for k in data['profiles'] if k.startswith('google:')])} google key(s)")
|
| 754 |
+
PYEOF
|
| 755 |
+
|
| 756 |
+
openclaw doctor --fix || true
|
| 757 |
+
|
| 758 |
+
# 安裝 liang-tavily-search skill 到 workspace/skills(最高優先級加載路徑)
|
| 759 |
+
# 僅在尚未安裝時執行,避免每次重啟都重複安裝
|
| 760 |
+
TAVILY_SKILL_DIR="/root/.openclaw/workspace/skills/liang-tavily-search"
|
| 761 |
+
if [ ! -d "$TAVILY_SKILL_DIR" ]; then
|
| 762 |
+
echo "Installing liang-tavily-search skill to workspace/skills..."
|
| 763 |
+
mkdir -p /root/.openclaw/workspace/skills
|
| 764 |
+
# clawhub 在指定 cwd 下安裝到 ./skills,指向 workspace 目錄
|
| 765 |
+
(cd /root/.openclaw/workspace && clawhub install liang-tavily-search) || \
|
| 766 |
+
echo "Warning: liang-tavily-search skill install failed, continuing anyway."
|
| 767 |
+
else
|
| 768 |
+
echo "liang-tavily-search skill already installed, skipping."
|
| 769 |
+
fi
|
| 770 |
+
|
| 771 |
+
# 探測 Playwright 下載的 Chromium 實際路徑,並動態修正 openclaw.json 中的 executablePath
|
| 772 |
+
# (Playwright 版本升級時 chromium-XXXX 目錄號可能變化)
|
| 773 |
+
CHROMIUM_EXEC=$(find /root/.cache/ms-playwright -name "chrome" -path "*/chrome-linux64/chrome" 2>/dev/null | head -1)
|
| 774 |
+
if [ -n "$CHROMIUM_EXEC" ]; then
|
| 775 |
+
echo "Detected Chromium at: $CHROMIUM_EXEC"
|
| 776 |
+
# 用 Python 就地修正 executablePath,避免 sed 處理特殊字符問題
|
| 777 |
+
python3 - "$CHROMIUM_EXEC" << 'PYEOF'
|
| 778 |
+
import sys, json, os
|
| 779 |
+
path = sys.argv[1]
|
| 780 |
+
cfg_file = "/root/.openclaw/openclaw.json"
|
| 781 |
+
if not os.path.exists(cfg_file):
|
| 782 |
+
print("openclaw.json not found, skip executablePath patch")
|
| 783 |
+
sys.exit(0)
|
| 784 |
+
with open(cfg_file) as f:
|
| 785 |
+
data = json.load(f)
|
| 786 |
+
browser = data.get("browser", {})
|
| 787 |
+
if browser.get("executablePath") != path:
|
| 788 |
+
browser["executablePath"] = path
|
| 789 |
+
data["browser"] = browser
|
| 790 |
+
with open(cfg_file, "w") as f:
|
| 791 |
+
json.dump(data, f, indent=2)
|
| 792 |
+
print(f"executablePath updated to: {path}")
|
| 793 |
+
else:
|
| 794 |
+
print("executablePath already correct, no change needed")
|
| 795 |
+
PYEOF
|
| 796 |
+
else
|
| 797 |
+
echo "Warning: Chromium executable not found under /root/.cache/ms-playwright, browser tool may need manual executablePath config"
|
| 798 |
+
fi
|
| 799 |
+
|
| 800 |
+
# 微信個人號接入:gateway 啟動後後台執行掃碼登錄
|
| 801 |
+
# 二維碼通過 qrcode-terminal 打印到 stdout(HF Space Logs 可直接看到)
|
| 802 |
+
# 若已登錄過(存在 session),插件會跳過掃碼直接完成;首次部署需在 Logs 裡掃碼
|
| 803 |
+
# ENABLE_WEIXIN:設為 false 可跳過(默認啟用)
|
| 804 |
+
# 注意:v2.x 插件通過 Gateway WebSocket 接入,需等 gateway 就緒後再執行 login
|
| 805 |
+
if [ "${ENABLE_WEIXIN:-true}" != "false" ]; then
|
| 806 |
+
echo "====== WeChat (微信) ClawBot Setup ======"
|
| 807 |
+
echo "If QR code appears below in logs (~30s), scan it with WeChat to bind your account."
|
| 808 |
+
echo "========================================="
|
| 809 |
+
(
|
| 810 |
+
sleep 30 # 等待 gateway 完全啟動
|
| 811 |
+
openclaw channels login --channel openclaw-weixin 2>&1 || \
|
| 812 |
+
echo "WeChat login step finished (or skipped if already configured)."
|
| 813 |
+
) &
|
| 814 |
+
echo "====== WeChat Login initiated in background ======"
|
| 815 |
+
fi
|
| 816 |
+
|
| 817 |
+
# 後台啟動 Gateway,待其就緒後自動批准最新的設備配對請求
|
| 818 |
+
# 解決 "bind: lan" 模式下 browser 工具首次連接需要配對審批的問題
|
| 819 |
+
# 注意:新版 openclaw (2026.4.x) approve --latest 已改為 preview-only,
|
| 820 |
+
# 必須先 list --json 取得 requestId,再用精確 ID 執行 approve
|
| 821 |
+
(
|
| 822 |
+
echo "Waiting for Gateway to become ready before approving device pairs..."
|
| 823 |
+
sleep 45
|
| 824 |
+
for i in $(seq 1 5); do
|
| 825 |
+
REQUEST_ID=$(openclaw devices list --json 2>/dev/null | python3 -c "
|
| 826 |
+
import sys, json
|
| 827 |
+
try:
|
| 828 |
+
data = json.load(sys.stdin)
|
| 829 |
+
pending = [d for d in data if d.get('status') == 'pending']
|
| 830 |
+
if pending:
|
| 831 |
+
print(pending[-1]['requestId'])
|
| 832 |
+
except Exception:
|
| 833 |
+
pass
|
| 834 |
+
" 2>/dev/null)
|
| 835 |
+
if [ -n "$REQUEST_ID" ]; then
|
| 836 |
+
if openclaw devices approve "$REQUEST_ID" 2>/dev/null; then
|
| 837 |
+
echo "Approved device pair request: $REQUEST_ID"
|
| 838 |
+
break
|
| 839 |
+
fi
|
| 840 |
+
fi
|
| 841 |
+
echo "No pending device pairs yet (attempt $i/5), retrying in 5s..."
|
| 842 |
+
sleep 5
|
| 843 |
+
done
|
| 844 |
+
) &
|
| 845 |
+
|
| 846 |
+
exec openclaw gateway run --port $PORT
|
| 847 |
+
EOF
|
| 848 |
+
|
| 849 |
+
RUN chmod +x /usr/local/bin/start-openclaw
|
| 850 |
+
|
| 851 |
+
# 12. code-server + nginx 啟動包裝腳本
|
| 852 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 853 |
+
# 架構說明:
|
| 854 |
+
# HF Space 對外只暴露單一端口 $PORT(默認 7860),因此:
|
| 855 |
+
# - nginx 監聽 $PORT(對外,由本腳本在運行時寫入配置)
|
| 856 |
+
# - openclaw gateway 監聽 7862(內部,通過覆蓋 PORT=7862 傳給 start-openclaw)
|
| 857 |
+
# - code-server 監聽 13337(內部)
|
| 858 |
+
# nginx 路由:/ide/ -> code-server:13337(nginx 剝離 /ide/ 前綴),/ -> gateway:7862
|
| 859 |
+
#
|
| 860 |
+
# 關鍵設計:
|
| 861 |
+
# start-openclaw 原腳本完全不修改,末尾是 `exec openclaw gateway run --port $PORT`。
|
| 862 |
+
# 此處以普通後台進程方式(非 exec)啟動它,並覆蓋 PORT=7862 使 gateway 監聽內部端口。
|
| 863 |
+
# start-openclaw 腳本內所有後台任務(備份循環、微信登錄、設備配對審批等)均在
|
| 864 |
+
# `exec openclaw gateway run` 前通過 & 後台啟動,因此全部照常運行,不受影響。
|
| 865 |
+
#
|
| 866 |
+
# code-server 說明:
|
| 867 |
+
# code-server 已移除 --base-path 參數(多年前即廢棄),
|
| 868 |
+
# subpath 反向代理通過 nginx location /ide/ + proxy_pass 末尾帶 / 實現路徑剝離。
|
| 869 |
+
# 登錄後 302 重定向通過 proxy_redirect 重寫回 /ide/ 前綴,否則會跳到 / 而非 /ide/。
|
| 870 |
+
#
|
| 871 |
+
# nginx 配置注意:
|
| 872 |
+
# nginx 配置文件不支持 bash 變量語法(${}),
|
| 873 |
+
# 所有動態端口值通過 sed 替換佔位符在運行時寫入。
|
| 874 |
+
RUN cat <<'EOF' > /usr/local/bin/start-openclaw-code-server
|
| 875 |
+
#!/bin/bash
|
| 876 |
+
set -e
|
| 877 |
+
|
| 878 |
+
# HuggingFace Space 對外端口(由 HF 平台注入,默認 7860)
|
| 879 |
+
LISTEN_PORT="${PORT:-7860}"
|
| 880 |
+
# openclaw gateway 內部端口(避免與 nginx 對外端口衝突)
|
| 881 |
+
GATEWAY_PORT=7862
|
| 882 |
+
# code-server 內部端口
|
| 883 |
+
CODE_SERVER_PORT=13337
|
| 884 |
+
|
| 885 |
+
echo "=== start-openclaw-code-server ==="
|
| 886 |
+
echo "External listen port : ${LISTEN_PORT}"
|
| 887 |
+
echo "Gateway internal port : ${GATEWAY_PORT}"
|
| 888 |
+
echo "code-server internal port: ${CODE_SERVER_PORT}"
|
| 889 |
+
|
| 890 |
+
# ── 1. 後台啟動原版 start-openclaw(覆蓋 PORT 使 gateway 監聽內部端口)──────
|
| 891 |
+
# start-openclaw 內所有邏輯(恢復備份、寫配置、後台備份循環、微信登錄、設備配對審批等)
|
| 892 |
+
# 全部照常執行,僅 gateway 監聽端口從 $PORT 改為內部 $GATEWAY_PORT
|
| 893 |
+
mkdir -p /root/.openclaw/logs
|
| 894 |
+
PORT=${GATEWAY_PORT} bash /usr/local/bin/start-openclaw 2>&1 | tee /root/.openclaw/logs/openclaw-main.log &
|
| 895 |
+
echo "start-openclaw launched in background (gateway port=${GATEWAY_PORT})"
|
| 896 |
+
|
| 897 |
+
# ── 2. 等待 gateway 內部就緒(最多 120 秒)──────────────────────────────────
|
| 898 |
+
echo "Waiting for openclaw gateway on internal port ${GATEWAY_PORT}..."
|
| 899 |
+
for i in $(seq 1 60); do
|
| 900 |
+
if curl -fsS http://127.0.0.1:${GATEWAY_PORT}/ >/dev/null 2>&1; then
|
| 901 |
+
echo "Gateway is up after $((i * 2))s."
|
| 902 |
+
break
|
| 903 |
+
fi
|
| 904 |
+
sleep 2
|
| 905 |
+
done
|
| 906 |
+
|
| 907 |
+
# ── 3. 啟動 code-server(後台,僅本機監聽)──────────────────────────────────
|
| 908 |
+
# 注意:code-server 已移除 --base-path 參數,subpath 由 nginx 的 proxy_pass 末尾 / 處理
|
| 909 |
+
# PASSWORD 通過環境變量傳入(code-server 官方支持的方式)
|
| 910 |
+
mkdir -p /root/.openclaw/logs /root/.code-server
|
| 911 |
+
|
| 912 |
+
export PASSWORD="${CODE_SERVER_PASSWORD:-changeme123!}"
|
| 913 |
+
code-server \
|
| 914 |
+
--bind-addr 127.0.0.1:${CODE_SERVER_PORT} \
|
| 915 |
+
--auth password \
|
| 916 |
+
--disable-telemetry \
|
| 917 |
+
--disable-update-check \
|
| 918 |
+
--user-data-dir /root/.code-server \
|
| 919 |
+
--extensions-dir /root/.code-server/extensions \
|
| 920 |
+
/root/.openclaw \
|
| 921 |
+
>> /root/.openclaw/logs/code-server.log 2>&1 &
|
| 922 |
+
echo "code-server launched (port=${CODE_SERVER_PORT})"
|
| 923 |
+
|
| 924 |
+
# ── 4. 生成 nginx 配置並啟動 ────────────────────────────────────────────────
|
| 925 |
+
# nginx 不支持 bash 變量語法,所有動態值通過 sed 替換佔位符寫入
|
| 926 |
+
rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf
|
| 927 |
+
|
| 928 |
+
cat > /etc/nginx/conf.d/openclaw-ide.conf <<'NGINX'
|
| 929 |
+
server {
|
| 930 |
+
listen PLACEHOLDER_LISTEN_PORT;
|
| 931 |
+
server_name _;
|
| 932 |
+
client_max_body_size 100M;
|
| 933 |
+
|
| 934 |
+
access_log /dev/stdout;
|
| 935 |
+
error_log /dev/stderr warn;
|
| 936 |
+
|
| 937 |
+
# IDE(code-server):nginx 剝離 /ide/ 前綴後轉發到 code-server 根路徑
|
| 938 |
+
# proxy_pass 末尾帶 / 是關鍵:nginx 自動將 /ide/xxx 重寫為 /xxx 再轉發
|
| 939 |
+
# proxy_redirect:code-server 登錄後發 302 跳轉到 /,需重寫回 /ide/ 否則會進入 openclaw
|
| 940 |
+
location /ide/ {
|
| 941 |
+
proxy_pass http://127.0.0.1:PLACEHOLDER_CODE_SERVER_PORT/;
|
| 942 |
+
proxy_http_version 1.1;
|
| 943 |
+
proxy_set_header Host $host;
|
| 944 |
+
proxy_set_header Upgrade $http_upgrade;
|
| 945 |
+
proxy_set_header Connection "upgrade";
|
| 946 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 947 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 948 |
+
proxy_redirect / /ide/;
|
| 949 |
+
proxy_read_timeout 86400;
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
# 其餘請求:代理到 openclaw gateway
|
| 953 |
+
location / {
|
| 954 |
+
proxy_pass http://127.0.0.1:PLACEHOLDER_GATEWAY_PORT/;
|
| 955 |
+
proxy_http_version 1.1;
|
| 956 |
+
proxy_set_header Host $host;
|
| 957 |
+
proxy_set_header Upgrade $http_upgrade;
|
| 958 |
+
proxy_set_header Connection "upgrade";
|
| 959 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 960 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 961 |
+
proxy_read_timeout 86400;
|
| 962 |
+
}
|
| 963 |
+
}
|
| 964 |
+
NGINX
|
| 965 |
+
|
| 966 |
+
sed -i \
|
| 967 |
+
"s/PLACEHOLDER_LISTEN_PORT/${LISTEN_PORT}/g;
|
| 968 |
+
s/PLACEHOLDER_CODE_SERVER_PORT/${CODE_SERVER_PORT}/g;
|
| 969 |
+
s/PLACEHOLDER_GATEWAY_PORT/${GATEWAY_PORT}/g" \
|
| 970 |
+
/etc/nginx/conf.d/openclaw-ide.conf
|
| 971 |
+
|
| 972 |
+
echo "nginx config:"
|
| 973 |
+
cat /etc/nginx/conf.d/openclaw-ide.conf
|
| 974 |
+
|
| 975 |
+
nginx -t
|
| 976 |
+
echo "Starting nginx (foreground)..."
|
| 977 |
+
exec nginx -g 'daemon off; error_log /dev/stderr warn;'
|
| 978 |
+
EOF
|
| 979 |
+
|
| 980 |
+
RUN chmod +x /usr/local/bin/start-openclaw-code-server
|
| 981 |
+
|
| 982 |
+
EXPOSE 7860
|
| 983 |
+
|
| 984 |
+
# IDE(code-server)訪問方式(Space 啟動後):
|
| 985 |
+
# 主界面(OpenClaw): https://<your-space>.hf.space/
|
| 986 |
+
# IDE(VS Code) : https://<your-space>.hf.space/ide/
|
| 987 |
+
# 密碼由環境變量 CODE_SERVER_PASSWORD 控制
|
| 988 |
+
# 默認:changeme123! ← 建議在 HF Space Secrets 中覆蓋
|
| 989 |
+
CMD ["/usr/local/bin/start-openclaw-code-server"]
|