Spaces:
Running
Running
| # 核心鏡像選擇 | |
| FROM node:24-slim | |
| # 1. 基礎依賴補全 (合併了 layer 減少體積) | |
| # 注意:code-server 通過官方安裝腳本安裝,nginx 用於反向代理 | |
| RUN apt-get update && apt-get install -y --no-install-recommends \ | |
| git openssh-client build-essential python3 python3-pip \ | |
| g++ make ca-certificates curl wget nginx \ | |
| && rm -rf /var/lib/apt/lists/* | |
| # 1.1. 安裝 code-server(瀏覽器版 VS Code,自帶 terminal) | |
| # 使用官方安裝腳本,自動適配架構和最新版本 | |
| RUN curl -fsSL https://code-server.dev/install.sh | sh | |
| # 2. 安裝 GitHub CLI (gh) — 使用官方 APT 倉庫,避免社區版 API 相容性問題 | |
| RUN mkdir -p -m 755 /etc/apt/keyrings \ | |
| && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | |
| && cat $out | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ | |
| && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ | |
| && mkdir -p -m 755 /etc/apt/sources.list.d \ | |
| && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ | |
| | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ | |
| && apt-get update \ | |
| && apt-get install -y --no-install-recommends gh \ | |
| && rm -rf /var/lib/apt/lists/* \ | |
| && gh --version | |
| # 3. 安裝 HF 數據交互工具 | |
| RUN pip3 install --no-cache-dir huggingface_hub --break-system-packages | |
| # 4. 構建環境與 Git 協議優化 | |
| RUN update-ca-certificates && \ | |
| git config --global http.sslVerify false && \ | |
| git config --global url."https://github.com/".insteadOf ssh://git@github.com/ | |
| # 5. OpenClaw 核心安裝 | |
| RUN npm install -g openclaw@latest --unsafe-perm | |
| # 6. 安裝 ClawHub CLI | |
| RUN npm install -g clawhub --unsafe-perm \ | |
| && clawhub -V || true | |
| # 6.2. 安裝 Playwright 完整包(openclaw browser 高級功能必需:snapshot/screenshot/PDF/navigate) | |
| # 注意:必須安裝完整 playwright,而非 playwright-core;openclaw 在其 node_modules 內已含 playwright-core | |
| RUN npm install -g playwright --unsafe-perm | |
| # 6.3. 安裝 Chromium 及系統依賴庫(使用 Playwright 官方腳本,一步搞定 100+ 依賴) | |
| RUN npx playwright install chromium --with-deps | |
| # 7. 安裝釘釘和企業微信插件 | |
| RUN openclaw plugins install @dingtalk-real-ai/dingtalk-connector | |
| RUN openclaw plugins install @wecom/wecom-openclaw-plugin | |
| # 7.1. 預安裝微信個人號插件本體(腾讯官方 iLink 協議) | |
| # 注意:插件本體在構建時安裝,掃碼登錄在運行時進行(二維碼會打印到 HF Logs) | |
| RUN openclaw plugins install @tencent-weixin/openclaw-weixin || true | |
| # 8. 環境變量默認值 | |
| ENV PORT=7860 \ | |
| OPENCLAW_GATEWAY_MODE=local \ | |
| HOME=/root \ | |
| PYTHONUNBUFFERED=1 | |
| # 9. 寫入 Python 同步引擎 | |
| RUN cat <<'EOF' > /usr/local/bin/sync.py | |
| import os, sys, tarfile, shutil | |
| from huggingface_hub import HfApi, hf_hub_download | |
| from datetime import datetime, timedelta | |
| api = HfApi() | |
| repo_id = os.getenv("HF_DATASET") | |
| token = os.getenv("HF_TOKEN") | |
| OPENCLAW_LOCAL = "/root/.openclaw" | |
| # ── 通用工具函數 ────────────────────────────────────────────────────────────── | |
| def _parse_skip_list(env_var): | |
| """解析逗號分隔的跳過列表,返回規範化路徑集合(支持多級子目錄)。""" | |
| raw = os.getenv(env_var, "").strip() | |
| if not raw: | |
| return set() | |
| return {s.strip().strip("/") for s in raw.split(",") if s.strip()} | |
| def _is_skipped(rel_path, skip_set): | |
| """ | |
| 判斷 rel_path(相對於 /root/.openclaw 的路徑)是否應跳過。 | |
| 支持精確匹配及前綴匹配(即跳過某目錄下所有子路徑)。 | |
| """ | |
| rel = rel_path.strip("/") | |
| for skip in skip_set: | |
| if rel == skip or rel.startswith(skip + "/"): | |
| return True | |
| return False | |
| def _walk_local(base_dir, skip_set=None): | |
| """ | |
| 遞歸遍歷 base_dir 下所有文件,返回 (local_abs_path, rel_to_base) 列表。 | |
| rel_to_base 是相對於 base_dir 的路徑(不含前導斜杠)。 | |
| skip_set 中的路徑是相對於 OPENCLAW_LOCAL 的路徑。 | |
| """ | |
| results = [] | |
| if not os.path.isdir(base_dir): | |
| return results | |
| for dirpath, dirnames, filenames in os.walk(base_dir): | |
| for fname in filenames: | |
| abs_path = os.path.join(dirpath, fname) | |
| rel_to_base = os.path.relpath(abs_path, base_dir) | |
| if skip_set is not None: | |
| # 計算相對於 OPENCLAW_LOCAL 的路徑用於 skip 匹配 | |
| rel_to_openclaw = os.path.relpath(abs_path, OPENCLAW_LOCAL) | |
| if _is_skipped(rel_to_openclaw, skip_set): | |
| continue | |
| results.append((abs_path, rel_to_base)) | |
| return results | |
| # ── restore() ──────────────────────────────────────────────────────────────── | |
| def restore(): | |
| if not repo_id or not token: | |
| print("Skip Restore: HF_DATASET or HF_TOKEN not set") | |
| return | |
| # RESTORE_SKIP=all:不恢復任何 tar.gz 內文件到本地,直接返回 | |
| restore_skip_raw = os.getenv("RESTORE_SKIP", "").strip() | |
| if restore_skip_raw == "all": | |
| print("Restore skip: RESTORE_SKIP=all, skipping all restore.") | |
| return | |
| # ── 方案 B:以 Dataset 中的 initialized.flag 作為觸發條件 ───────────────── | |
| # 邏輯: | |
| # - Dataset 中有 initialized.flag → 普通 restart → 執行 restore,完成後寫入標記 | |
| # - Dataset 中无 initialized.flag → 首次部署 → 跳過 restore | |
| # - FORCE_RESTORE=true → 無視標記,強制執行 restore(用於 factory rebuild) | |
| # | |
| # Factory rebuild 場景的兩種觸發方式(任選其一): | |
| # 1. 在 HF Space Settings 中設置環境變量 FORCE_RESTORE=true,重建後再刪除此變量 | |
| # 2. 直接在 HF Dataset 網頁上新建 initialized.flag 文件,再點 Restart | |
| INIT_FLAG = "initialized.flag" | |
| force_restore = os.getenv("FORCE_RESTORE", "").strip().lower() in ("true", "1", "yes") | |
| try: | |
| all_files = list(api.list_repo_files(repo_id=repo_id, repo_type="dataset", token=token)) | |
| except Exception as e: | |
| print(f"Restore Error (list_repo_files): {e}") | |
| return | |
| flag_exists = INIT_FLAG in all_files | |
| if not flag_exists and not force_restore: | |
| print("Restore skip: initialized.flag not found, this is first deploy or rebuild.") | |
| import io | |
| flag_content = b"initialized\n" | |
| api.upload_file( | |
| path_or_fileobj=io.BytesIO(flag_content), | |
| path_in_repo=INIT_FLAG, | |
| repo_id=repo_id, | |
| repo_type="dataset", | |
| token=token, | |
| commit_message="Create initialized.flag on first deploy", | |
| ) | |
| print("initialized.flag created in Dataset.") | |
| return | |
| if force_restore: | |
| print("Restore: FORCE_RESTORE=true, ignoring initialized.flag.") | |
| else: | |
| print("Restore: initialized.flag found in Dataset,this is a normal restart. ") | |
| # 解析跳過列表(用於在解壓時逐條目跳過,不是解壓後刪除) | |
| skip_set = _parse_skip_list("RESTORE_SKIP") | |
| try: | |
| # ── 從 tar.gz 備份恢復 ─────────────────────────────────────────────── | |
| # 備份範圍:/root/.openclaw 下所有文件及文件夾(保持完整目錄結構) | |
| # tar 包內 arcname 直接對應 .openclaw/ 下的相對路徑,解壓目標為 /root/.openclaw/ | |
| # RESTORE_SKIP:逗號分隔,填寫相對於 /root/.openclaw 的路徑 | |
| # 支持多級子目錄,例如:RESTORE_SKIP=agents/main/sessions,cron,extensions/foo/bar | |
| # 留空或不設置 = 全部恢復(默認行為) | |
| # RESTORE_SKIP=all = 跳過全部恢復(已在上方提前返回) | |
| now = datetime.now() | |
| for i in range(5): | |
| day = (now - timedelta(days=i)).strftime("%Y-%m-%d") | |
| name = f"backup_{day}.tar.gz" | |
| if name in all_files: | |
| print(f"Downloading {name}...") | |
| path = hf_hub_download(repo_id=repo_id, filename=name, repo_type="dataset", token=token) | |
| os.makedirs(OPENCLAW_LOCAL, exist_ok=True) | |
| with tarfile.open(path, "r:gz") as tar: | |
| for member in tar.getmembers(): | |
| if _is_skipped(member.name, skip_set): | |
| print(f"Restore skip (RESTORE_SKIP): skipping {member.name}") | |
| continue | |
| tar.extract(member, path=OPENCLAW_LOCAL) | |
| print(f"Success: Restored from {name}") | |
| break | |
| # ── 寫入 initialized.flag 到 Dataset ──────────────────────────────── | |
| # 無論是否找到備份,只要走到這裡就說明應當完成初始化流程,寫入標記 | |
| import io | |
| flag_content = f"initialized at container startup\n".encode() | |
| api.upload_file( | |
| path_or_fileobj=io.BytesIO(flag_content), | |
| path_in_repo=INIT_FLAG, | |
| repo_id=repo_id, | |
| repo_type="dataset", | |
| token=token, | |
| commit_message="Set initialized.flag", | |
| ) | |
| print(f"initialized.flag written to Dataset.") | |
| except Exception as e: | |
| print(f"Restore Error: {e}") | |
| # ── backup() ────────────────────────────────────────────────────────────────── | |
| def backup(): | |
| if not repo_id or not token: | |
| print("Skip Backup: HF_DATASET or HF_TOKEN not set") | |
| return | |
| # ── tar.gz 打包備份 ────────────────────────────────────────────────────── | |
| # 備份範圍:/root/.openclaw 下所有文件及文件夾(完整目錄結構) | |
| # tar 包解壓目標為 /root/.openclaw/,arcname 對應其內部相對路徑 | |
| # | |
| # BACKUP_TAR_SKIP:tar.gz 備份時跳過的文件/目錄(相對於 /root/.openclaw) | |
| # 格式:逗號分隔,支持多級子目錄 | |
| # 例如:BACKUP_TAR_SKIP=agents/main/sessions,cron,extensions/foo/bar | |
| # 留空或不設置 = 備份全部 | |
| # BACKUP_TAR_SKIP=all = 不執行 tar.gz 備份,不生成任何 tar.gz 備份文件 | |
| backup_tar_skip_raw = os.getenv("BACKUP_TAR_SKIP", "").strip() | |
| if backup_tar_skip_raw == "all": | |
| print("Backup tar.gz skip: BACKUP_TAR_SKIP=all, skipping tar.gz backup.") | |
| return | |
| try: | |
| tar_skip_set = _parse_skip_list("BACKUP_TAR_SKIP") | |
| day = datetime.now().strftime("%Y-%m-%d") | |
| name = f"backup_{day}.tar.gz" | |
| with tarfile.open(name, "w:gz") as tar: | |
| for abs_path, rel_to_base in _walk_local(OPENCLAW_LOCAL, skip_set=tar_skip_set): | |
| arcname = rel_to_base # 相對於 .openclaw/ | |
| tar.add(abs_path, arcname=arcname) | |
| api.upload_file(path_or_fileobj=name, path_in_repo=name, repo_id=repo_id, repo_type="dataset", token=token) | |
| print(f"Backup {name} Success.") | |
| except Exception as e: | |
| print(f"Backup tar.gz Error: {e}") | |
| if __name__ == "__main__": | |
| if len(sys.argv) > 1 and sys.argv[1] == "backup": | |
| backup() | |
| else: | |
| restore() | |
| EOF | |
| # 10. 寫入 providers JSON 生成腳本 | |
| RUN cat <<'EOF' > /usr/local/bin/build_providers.py | |
| import os, json | |
| def get(name): | |
| return os.environ.get(name, "") | |
| providers = {} | |
| for i in range(1, 6): | |
| pname = get(f"provide{i}") | |
| baseurl = get(f"baseUrl{i}") | |
| apikey = get(f"apiKey{i}") | |
| api = get(f"provide{i}_api1") | |
| if not pname or not baseurl or not api: | |
| continue | |
| models = [] | |
| for m in range(1, 6): | |
| mid = get(f"provide{i}_models_id{m}") | |
| mname = get(f"provide{i}_models_name{m}") | |
| inp = get(f"provide{i}_models_input{m}") | |
| if not mid: | |
| continue | |
| input_val = ["text", "image"] if "image" in inp.lower() else ["text"] | |
| models.append({ | |
| "id": mid, | |
| "name": mname, | |
| "input": input_val, | |
| "reasoning": False, | |
| "contextWindow": 2000000, | |
| "maxTokens": 2000000, | |
| "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0} | |
| }) | |
| if not models: | |
| continue | |
| providers[pname] = { | |
| "baseUrl": baseurl, | |
| "apiKey": apikey, | |
| "api": api, | |
| "models": models | |
| } | |
| entries = [] | |
| for k, v in providers.items(): | |
| block = json.dumps({k: v}, indent=4, ensure_ascii=False) | |
| inner = block.strip()[1:-1].strip() | |
| indented = "\n".join(" " + line for line in inner.split("\n")) | |
| entries.append(indented) | |
| print(",\n".join(entries)) | |
| EOF | |
| # 11. 寫入啟動控制邏輯 | |
| RUN cat <<'EOF' > /usr/local/bin/start-openclaw | |
| #!/bin/bash | |
| set -e | |
| # ── restore() 觸發條件判斷 ────────────────────────────────────────────────── | |
| # 判定邏輯已移入 sync.py restore() 函數內部(方案 B): | |
| # - Dataset 中無 initialized.flag → 首次部署 → 執行 restore,完成後寫入標記 | |
| # - Dataset 中有 initialized.flag → 普通 restart → 跳過 restore | |
| # - FORCE_RESTORE=true → 強制執行 restore(用於 factory rebuild) | |
| # 此處直接調用,觸發與否由 restore() 自行決定。 | |
| python3 /usr/local/bin/sync.py restore | |
| mkdir -p /root/.openclaw/sessions | |
| # provider1 model2~5 fallback 到自身 model1 | |
| provide1_models_id2="${provide1_models_id2:-$provide1_models_id1}" | |
| provide1_models_name2="${provide1_models_name2:-$provide1_models_name1}" | |
| provide1_models_api2="${provide1_models_api2:-$provide1_models_api1}" | |
| provide1_models_input2="${provide1_models_input2:-$provide1_models_input1}" | |
| provide1_models_id3="${provide1_models_id3:-$provide1_models_id1}" | |
| provide1_models_name3="${provide1_models_name3:-$provide1_models_name1}" | |
| provide1_models_api3="${provide1_models_api3:-$provide1_models_api1}" | |
| provide1_models_input3="${provide1_models_input3:-$provide1_models_input1}" | |
| provide1_models_id4="${provide1_models_id4:-$provide1_models_id1}" | |
| provide1_models_name4="${provide1_models_name4:-$provide1_models_name1}" | |
| provide1_models_api4="${provide1_models_api4:-$provide1_models_api1}" | |
| provide1_models_input4="${provide1_models_input4:-$provide1_models_input1}" | |
| provide1_models_id5="${provide1_models_id5:-$provide1_models_id1}" | |
| provide1_models_name5="${provide1_models_name5:-$provide1_models_name1}" | |
| provide1_models_api5="${provide1_models_api5:-$provide1_models_api1}" | |
| provide1_models_input5="${provide1_models_input5:-$provide1_models_input1}" | |
| # provider2 model2~5 fallback 到自身 model1 | |
| provide2_models_id2="${provide2_models_id2:-$provide2_models_id1}" | |
| provide2_models_name2="${provide2_models_name2:-$provide2_models_name1}" | |
| provide2_models_api2="${provide2_models_api2:-$provide2_models_api1}" | |
| provide2_models_input2="${provide2_models_input2:-$provide2_models_input1}" | |
| provide2_models_id3="${provide2_models_id3:-$provide2_models_id1}" | |
| provide2_models_name3="${provide2_models_name3:-$provide2_models_name1}" | |
| provide2_models_api3="${provide2_models_api3:-$provide2_models_api1}" | |
| provide2_models_input3="${provide2_models_input3:-$provide2_models_input1}" | |
| provide2_models_id4="${provide2_models_id4:-$provide2_models_id1}" | |
| provide2_models_name4="${provide2_models_name4:-$provide2_models_name1}" | |
| provide2_models_api4="${provide2_models_api4:-$provide2_models_api1}" | |
| provide2_models_input4="${provide2_models_input4:-$provide2_models_input1}" | |
| provide2_models_id5="${provide2_models_id5:-$provide2_models_id1}" | |
| provide2_models_name5="${provide2_models_name5:-$provide2_models_name1}" | |
| provide2_models_api5="${provide2_models_api5:-$provide2_models_api1}" | |
| provide2_models_input5="${provide2_models_input5:-$provide2_models_input1}" | |
| # provider3 model2~5 fallback 到自身 model1 | |
| provide3_models_id2="${provide3_models_id2:-$provide3_models_id1}" | |
| provide3_models_name2="${provide3_models_name2:-$provide3_models_name1}" | |
| provide3_models_api2="${provide3_models_api2:-$provide3_models_api1}" | |
| provide3_models_input2="${provide3_models_input2:-$provide3_models_input1}" | |
| provide3_models_id3="${provide3_models_id3:-$provide3_models_id1}" | |
| provide3_models_name3="${provide3_models_name3:-$provide3_models_name1}" | |
| provide3_models_api3="${provide3_models_api3:-$provide3_models_api1}" | |
| provide3_models_input3="${provide3_models_input3:-$provide3_models_input1}" | |
| provide3_models_id4="${provide3_models_id4:-$provide3_models_id1}" | |
| provide3_models_name4="${provide3_models_name4:-$provide3_models_name1}" | |
| provide3_models_api4="${provide3_models_api4:-$provide3_models_api1}" | |
| provide3_models_input4="${provide3_models_input4:-$provide3_models_input1}" | |
| provide3_models_id5="${provide3_models_id5:-$provide3_models_id1}" | |
| provide3_models_name5="${provide3_models_name5:-$provide3_models_name1}" | |
| provide3_models_api5="${provide3_models_api5:-$provide3_models_api1}" | |
| provide3_models_input5="${provide3_models_input5:-$provide3_models_input1}" | |
| # provider4 model2~5 fallback 到自身 model1 | |
| provide4_models_id2="${provide4_models_id2:-$provide4_models_id1}" | |
| provide4_models_name2="${provide4_models_name2:-$provide4_models_name1}" | |
| provide4_models_api2="${provide4_models_api2:-$provide4_models_api1}" | |
| provide4_models_input2="${provide4_models_input2:-$provide4_models_input1}" | |
| provide4_models_id3="${provide4_models_id3:-$provide4_models_id1}" | |
| provide4_models_name3="${provide4_models_name3:-$provide4_models_name1}" | |
| provide4_models_api3="${provide4_models_api3:-$provide4_models_api1}" | |
| provide4_models_input3="${provide4_models_input3:-$provide4_models_input1}" | |
| provide4_models_id4="${provide4_models_id4:-$provide4_models_id1}" | |
| provide4_models_name4="${provide4_models_name4:-$provide4_models_name1}" | |
| provide4_models_api4="${provide4_models_api4:-$provide4_models_api1}" | |
| provide4_models_input4="${provide4_models_input4:-$provide4_models_input1}" | |
| provide4_models_id5="${provide4_models_id5:-$provide4_models_id1}" | |
| provide4_models_name5="${provide4_models_name5:-$provide4_models_name1}" | |
| provide4_models_api5="${provide4_models_api5:-$provide4_models_api1}" | |
| provide4_models_input5="${provide4_models_input5:-$provide4_models_input1}" | |
| # provider5 model2~5 fallback 到自身 model1 | |
| provide5_models_id2="${provide5_models_id2:-$provide5_models_id1}" | |
| provide5_models_name2="${provide5_models_name2:-$provide5_models_name1}" | |
| provide5_models_api2="${provide5_models_api2:-$provide5_models_api1}" | |
| provide5_models_input2="${provide5_models_input2:-$provide5_models_input1}" | |
| provide5_models_id3="${provide5_models_id3:-$provide5_models_id1}" | |
| provide5_models_name3="${provide5_models_name3:-$provide5_models_name1}" | |
| provide5_models_api3="${provide5_models_api3:-$provide5_models_api1}" | |
| provide5_models_input3="${provide5_models_input3:-$provide5_models_input1}" | |
| provide5_models_id4="${provide5_models_id4:-$provide5_models_id1}" | |
| provide5_models_name4="${provide5_models_name4:-$provide5_models_name1}" | |
| provide5_models_api4="${provide5_models_api4:-$provide5_models_api1}" | |
| provide5_models_input4="${provide5_models_input4:-$provide5_models_input1}" | |
| provide5_models_id5="${provide5_models_id5:-$provide5_models_id1}" | |
| provide5_models_name5="${provide5_models_name5:-$provide5_models_name1}" | |
| provide5_models_api5="${provide5_models_api5:-$provide5_models_api1}" | |
| provide5_models_input5="${provide5_models_input5:-$provide5_models_input1}" | |
| # google model2~5 fallback | |
| google_model2="${google_model2:-$google_model1}" | |
| google_model3="${google_model3:-$google_model1}" | |
| google_model4="${google_model4:-$google_model1}" | |
| google_model5="${google_model5:-$google_model1}" | |
| # primary_model / primary_imageModel fallback | |
| primary_model="${primary_model:-${provide1}/${provide1_models_id1}}" | |
| primary_imageModel="${primary_imageModel:-${provide1}/${provide1_models_id1}}" | |
| PROVIDERS_JSON=$(python3 /usr/local/bin/build_providers.py) | |
| # ── 動態 Channel 配置:DINGTALK 變量未設置時只寫入 disabled 配置,避免健康檢查拋出未捕獲異常崩潰進程 ── | |
| if [ -n "${DINGTALK_clientId}" ] && [ -n "${DINGTALK_clientSecret}" ]; then | |
| DINGTALK_CHANNEL_JSON='"dingtalk-connector": { | |
| "enabled": true, | |
| "clientId": "${DINGTALK_clientId}", | |
| "clientSecret": "${DINGTALK_clientSecret}" | |
| },' | |
| else | |
| DINGTALK_CHANNEL_JSON='"dingtalk-connector": { | |
| "enabled": false | |
| },' | |
| fi | |
| cat > /root/.openclaw/openclaw.json <<EOT | |
| { | |
| "models": { | |
| "mode": "merge", | |
| "providers": { | |
| ${PROVIDERS_JSON} | |
| } | |
| }, | |
| "agents": { | |
| "defaults": { | |
| "model": { | |
| "primary": "${primary_model}" | |
| }, | |
| "imageModel": { | |
| "primary": "${primary_imageModel}" | |
| }, | |
| "models": { | |
| "${provide1}/${provide1_models_id1}": {}, | |
| "${provide1}/${provide1_models_id2}": {}, | |
| "${provide1}/${provide1_models_id3}": {}, | |
| "${provide1}/${provide1_models_id4}": {}, | |
| "${provide1}/${provide1_models_id5}": {}, | |
| "${provide2}/${provide2_models_id1}": {}, | |
| "${provide2}/${provide2_models_id2}": {}, | |
| "${provide2}/${provide2_models_id3}": {}, | |
| "${provide2}/${provide2_models_id4}": {}, | |
| "${provide2}/${provide2_models_id5}": {}, | |
| "${provide3}/${provide3_models_id1}": {}, | |
| "${provide3}/${provide3_models_id2}": {}, | |
| "${provide3}/${provide3_models_id3}": {}, | |
| "${provide3}/${provide3_models_id4}": {}, | |
| "${provide3}/${provide3_models_id5}": {}, | |
| "${provide4}/${provide4_models_id1}": {}, | |
| "${provide4}/${provide4_models_id2}": {}, | |
| "${provide4}/${provide4_models_id3}": {}, | |
| "${provide4}/${provide4_models_id4}": {}, | |
| "${provide4}/${provide4_models_id5}": {}, | |
| "${provide5}/${provide5_models_id1}": {}, | |
| "${provide5}/${provide5_models_id2}": {}, | |
| "${provide5}/${provide5_models_id3}": {}, | |
| "${provide5}/${provide5_models_id4}": {}, | |
| "${provide5}/${provide5_models_id5}": {}, | |
| "google/${google_model1}": {}, | |
| "google/${google_model2}": {}, | |
| "google/${google_model3}": {}, | |
| "google/${google_model4}": {}, | |
| "google/${google_model5}": {} | |
| } | |
| } | |
| }, | |
| "gateway": { | |
| "mode": "local", | |
| "bind": "lan", | |
| "port": ${PORT}, | |
| "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": "${OPENCLAW_PASSWORD}" | |
| }, | |
| "controlUi": { | |
| "allowInsecureAuth": true, | |
| "dangerouslyAllowHostHeaderOriginFallback": true | |
| } | |
| }, | |
| "cron": { | |
| "enabled": true, | |
| "store": "/root/.openclaw/cron/jobs.json", | |
| "maxConcurrentRuns": 1 | |
| }, | |
| "plugins": { | |
| "allow": ["dingtalk-connector", "wecom-openclaw-plugin", "openclaw-weixin"], | |
| "entries": { | |
| "dingtalk-connector": { | |
| "enabled": true | |
| }, | |
| "wecom-openclaw-plugin": { | |
| "enabled": true | |
| }, | |
| "openclaw-weixin": { | |
| "enabled": true | |
| } | |
| } | |
| }, | |
| "skills": { | |
| "entries": { | |
| "liang-tavily-search": { | |
| "enabled": true, | |
| "apiKey": "${TAVILY_API_KEY}" | |
| } | |
| } | |
| }, | |
| "channels": { | |
| ${DINGTALK_CHANNEL_JSON} | |
| "wecom": { | |
| "enabled": true, | |
| "botId": "${WECOM_BOT_ID}", | |
| "secret": "${WECOM_SECRET}", | |
| "dmPolicy": "open", | |
| "groupPolicy": "open", | |
| "messageType": "markdown", | |
| "debug": false, | |
| "allowFrom": ["*"] | |
| } | |
| }, | |
| "browser": { | |
| "enabled": true, | |
| "headless": true, | |
| "noSandbox": true, | |
| "defaultProfile": "openclaw", | |
| "executablePath": "/root/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome", | |
| "ssrfPolicy": { | |
| "dangerouslyAllowPrivateNetwork": true | |
| } | |
| }} | |
| EOT | |
| # 生成 cron jobs 定義文件(僅在不存在時寫入,避免覆蓋用戶在運行時添加的 job) | |
| # KEEP_ALIVE_MODEL:指定 keep-alive job 使用的模型,格式為 provider/model-id | |
| # 例如:KEEP_ALIVE_MODEL=google/gemini-flash-lite | |
| # 留空或不設置則由 openclaw 默認模型執行 | |
| mkdir -p /root/.openclaw/cron | |
| if [ ! -f /root/.openclaw/cron/jobs.json ]; then | |
| python3 - << 'PYEOF' | |
| import os, json | |
| enable_keep_alive = os.environ.get("ENABLE_KEEP_ALIVE", "false").strip().lower() in ("true", "1", "yes") | |
| if enable_keep_alive: | |
| payload = { | |
| "kind": "agentTurn", | |
| "message": "curl -s -o /dev/null -w '%{http_code}' -X OPTIONS https://heiyu-mo-openclaw.hf.space/", | |
| "lightContext": True | |
| } | |
| keep_alive_model = os.environ.get("KEEP_ALIVE_MODEL", "").strip() | |
| if keep_alive_model: | |
| payload["model"] = keep_alive_model | |
| jobs = { | |
| "version": 1, | |
| "jobs": [{ | |
| "id": "0f174587-f3cd-4ea6-90ef-887c845fdea0", | |
| "name": "keep-alive", | |
| "enabled": True, | |
| "createdAtMs": 1772522542926, | |
| "updatedAtMs": 1772594582735, | |
| "schedule": {"kind": "every", "everyMs": 3600000, "anchorMs": 1772522542926}, | |
| "sessionTarget": "isolated", | |
| "wakeMode": "now", | |
| "payload": payload, | |
| "delivery": {"mode": "none", "channel": "last"}, | |
| "state": { | |
| "nextRunAtMs": 1772598143029, | |
| "lastRunAtMs": 1772594543029, | |
| "lastRunStatus": "ok", | |
| "lastStatus": "ok", | |
| "lastDurationMs": 39706, | |
| "lastDelivered": False, | |
| "deliveryStatus": "not-delivered", | |
| "consecutiveErrors": 0 | |
| } | |
| }] | |
| } | |
| model_info = f"model={keep_alive_model}" if keep_alive_model else "model=default" | |
| print(f"Cron jobs.json initialized. keep-alive enabled, {model_info}") | |
| else: | |
| jobs = {"version": 1, "jobs": []} | |
| print("Cron jobs.json initialized. keep-alive disabled") | |
| with open("/root/.openclaw/cron/jobs.json", "w") as f: | |
| json.dump(jobs, f, separators=(",", ":")) | |
| PYEOF | |
| else | |
| echo "Cron jobs.json already exists, skipping init." | |
| fi | |
| # 增量備份循環(每 60 分鐘後台運行) | |
| (while true; do sleep 3600; python3 /usr/local/bin/sync.py backup; done) & | |
| # 把 HF Space Secrets 裡的 GEMINI key 寫入 auth-profiles.json | |
| mkdir -p /root/.openclaw/agents/main/agent | |
| python3 - << 'PYEOF' | |
| import os, json | |
| auth_file = "/root/.openclaw/agents/main/agent/auth-profiles.json" | |
| if os.path.exists(auth_file): | |
| with open(auth_file) as f: | |
| data = json.load(f) | |
| for pid, profile in data.get("profiles", {}).items(): | |
| if profile.get("type") == "api_key" and "token" in profile and "key" not in profile: | |
| profile["key"] = profile.pop("token") | |
| else: | |
| data = {"profiles": {}, "lastGood": {}} | |
| key_names = [ | |
| ("GEMINI_API_KEY_1", "google:key1"), | |
| ("GEMINI_API_KEY_2", "google:key2"), | |
| ("GEMINI_API_KEY_3", "google:key3"), | |
| ("GEMINI_API_KEY_4", "google:key4"), | |
| ("GEMINI_API_KEY_5", "google:key5"), | |
| ] | |
| first_profile_id = None | |
| for env_name, profile_id in key_names: | |
| key_val = os.environ.get(env_name, "").strip() | |
| if not key_val: | |
| continue | |
| data["profiles"][profile_id] = { | |
| "type": "api_key", | |
| "provider": "google", | |
| "key": key_val | |
| } | |
| if first_profile_id is None: | |
| first_profile_id = profile_id | |
| if first_profile_id: | |
| data["lastGood"]["google"] = first_profile_id | |
| with open(auth_file, "w") as f: | |
| json.dump(data, f, indent=2) | |
| print(f"auth-profiles.json written: {len([k for k in data['profiles'] if k.startswith('google:')])} google key(s)") | |
| PYEOF | |
| openclaw doctor --fix || true | |
| # 安裝 liang-tavily-search skill 到 workspace/skills(最高優先級加載路徑) | |
| # 僅在尚未安裝時執行,避免每次重啟都重複安裝 | |
| TAVILY_SKILL_DIR="/root/.openclaw/workspace/skills/liang-tavily-search" | |
| if [ ! -d "$TAVILY_SKILL_DIR" ]; then | |
| echo "Installing liang-tavily-search skill to workspace/skills..." | |
| mkdir -p /root/.openclaw/workspace/skills | |
| # clawhub 在指定 cwd 下安裝到 ./skills,指向 workspace 目錄 | |
| (cd /root/.openclaw/workspace && clawhub install liang-tavily-search) || \ | |
| echo "Warning: liang-tavily-search skill install failed, continuing anyway." | |
| else | |
| echo "liang-tavily-search skill already installed, skipping." | |
| fi | |
| # 探測 Playwright 下載的 Chromium 實際路徑,並動態修正 openclaw.json 中的 executablePath | |
| # (Playwright 版本升級時 chromium-XXXX 目錄號可能變化) | |
| CHROMIUM_EXEC=$(find /root/.cache/ms-playwright -name "chrome" -path "*/chrome-linux64/chrome" 2>/dev/null | head -1) | |
| if [ -n "$CHROMIUM_EXEC" ]; then | |
| echo "Detected Chromium at: $CHROMIUM_EXEC" | |
| # 用 Python 就地修正 executablePath,避免 sed 處理特殊字符問題 | |
| python3 - "$CHROMIUM_EXEC" << 'PYEOF' | |
| import sys, json, os | |
| path = sys.argv[1] | |
| cfg_file = "/root/.openclaw/openclaw.json" | |
| if not os.path.exists(cfg_file): | |
| print("openclaw.json not found, skip executablePath patch") | |
| sys.exit(0) | |
| with open(cfg_file) as f: | |
| data = json.load(f) | |
| browser = data.get("browser", {}) | |
| if browser.get("executablePath") != path: | |
| browser["executablePath"] = path | |
| data["browser"] = browser | |
| with open(cfg_file, "w") as f: | |
| json.dump(data, f, indent=2) | |
| print(f"executablePath updated to: {path}") | |
| else: | |
| print("executablePath already correct, no change needed") | |
| PYEOF | |
| else | |
| echo "Warning: Chromium executable not found under /root/.cache/ms-playwright, browser tool may need manual executablePath config" | |
| fi | |
| # 微信個人號接入:gateway 啟動後後台執行掃碼登錄 | |
| # 二維碼通過 qrcode-terminal 打印到 stdout(HF Space Logs 可直接看到) | |
| # 若已登錄過(存在 session),插件會跳過掃碼直接完成;首次部署需在 Logs 裡掃碼 | |
| # ENABLE_WEIXIN:設為 false 可跳過(默認啟用) | |
| # 注意:v2.x 插件通過 Gateway WebSocket 接入,需等 gateway 就緒後再執行 login | |
| if [ "${ENABLE_WEIXIN:-true}" != "false" ]; then | |
| echo "====== WeChat (微信) ClawBot Setup ======" | |
| echo "If QR code appears below in logs (~30s), scan it with WeChat to bind your account." | |
| echo "=========================================" | |
| ( | |
| sleep 30 # 等待 gateway 完全啟動 | |
| openclaw channels login --channel openclaw-weixin 2>&1 || \ | |
| echo "WeChat login step finished (or skipped if already configured)." | |
| ) & | |
| echo "====== WeChat Login initiated in background ======" | |
| fi | |
| # 後台啟動 Gateway,待其就緒後自動批准最新的設備配對請求 | |
| # 解決 "bind: lan" 模式下 browser 工具首次連接需要配對審批的問題 | |
| # 注意:新版 openclaw (2026.4.x) approve --latest 已改為 preview-only, | |
| # 必須先 list --json 取得 requestId,再用精確 ID 執行 approve | |
| ( | |
| echo "Waiting for Gateway to become ready before approving device pairs..." | |
| sleep 45 | |
| for i in $(seq 1 5); do | |
| REQUEST_ID=$(openclaw devices list --json 2>/dev/null | python3 -c " | |
| import sys, json | |
| try: | |
| data = json.load(sys.stdin) | |
| pending = [d for d in data if d.get('status') == 'pending'] | |
| if pending: | |
| print(pending[-1]['requestId']) | |
| except Exception: | |
| pass | |
| " 2>/dev/null) | |
| if [ -n "$REQUEST_ID" ]; then | |
| if openclaw devices approve "$REQUEST_ID" 2>/dev/null; then | |
| echo "Approved device pair request: $REQUEST_ID" | |
| break | |
| fi | |
| fi | |
| echo "No pending device pairs yet (attempt $i/5), retrying in 5s..." | |
| sleep 5 | |
| done | |
| ) & | |
| exec openclaw gateway run --port $PORT | |
| EOF | |
| RUN chmod +x /usr/local/bin/start-openclaw | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # 12. code-server 按需啟停管理器 + nginx 啟動包裝腳本 | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # | |
| # 【架構說明】 | |
| # HF Space 對外只暴露單一端口 $PORT(默認 7860),因此: | |
| # - nginx 監聽 $PORT(對外,由本腳本在運行時寫入配置) | |
| # - openclaw gateway 監聽 7862(內部,通過覆蓋 PORT=7862 傳給 start-openclaw) | |
| # - code-server 監聽 13337(內部,按需啟停) | |
| # nginx 路由:/ide/ -> code-server:13337(nginx 剝離 /ide/ 前綴),/ -> gateway:7862 | |
| # | |
| # 【按需啟停機制】(無需 nginx Lua 模塊,純 bash + Python 實現) | |
| # | |
| # 核心組件 1:/usr/local/bin/cs-manager(Python 守護進程) | |
| # - 監聽 Unix socket:/tmp/cs-manager.sock | |
| # - 接收來自 nginx 的觸發請求(通過 nginx error_page + proxy_pass 機制) | |
| # - 負責啟動、停止 code-server,維護"最後活躍時間戳"文件 | |
| # - 每分鐘檢查一次,若超過 IDE_IDLE_MINUTES 分鐘無請求,自動 kill code-server | |
| # | |
| # 核心組件 2:nginx 配置(/ide/ location) | |
| # - 設置 proxy_connect_timeout 極短(2s),code-server 未啟動時快速失敗 | |
| # - 通過 error_page 502/504 -> @ide_wakeup 捕獲連接失敗 | |
| # - @ide_wakeup location:反向代理到 cs-manager,cs-manager 啟動 code-server | |
| # 並返回 HTML 自動刷新頁(前端 5s 後自動重試 /ide/ 直到 code-server 就緒) | |
| # - 正常請求到達 /ide/ 時,通過 post_action 向 cs-manager 發心跳更新活躍時間戳 | |
| # | |
| # 【環境變量】 | |
| # IDE_IDLE_MINUTES 閒置自動關閉時間(分鐘),默認 30 | |
| # CODE_SERVER_PASSWORD code-server 登錄密碼,默認 changeme123! | |
| # ── 12a. 寫入 cs-manager 守護進程 ─────────────────────────────────────────── | |
| RUN cat <<'PYEOF' > /usr/local/bin/cs-manager | |
| #!/usr/bin/env python3 | |
| """ | |
| cs-manager: code-server 按需啟停守護進程 | |
| 監聽 Unix socket,提供兩個 HTTP 端點: | |
| GET /wakeup - 觸發啟動 code-server(若未運行),返回"啟動中"等待頁 | |
| GET /heartbeat - 更新最後活躍時間(nginx 每次成功代理 /ide/ 後調用) | |
| 後台定時任務: | |
| 每 60 秒檢查一次,若超過 IDE_IDLE_MINUTES 分鐘無 heartbeat,kill code-server | |
| """ | |
| import os, sys, time, signal, subprocess, threading, socket, re | |
| from http.server import HTTPServer, BaseHTTPRequestHandler | |
| # ── 配置 ───────────────────────────────────────────────────────────────────── | |
| SOCK_PATH = "/tmp/cs-manager.sock" | |
| CS_PORT = int(os.environ.get("CODE_SERVER_PORT", "13337")) | |
| CS_PASSWORD = os.environ.get("CODE_SERVER_PASSWORD", "changeme123!") | |
| CS_USER_DATA_DIR = "/root/.code-server" | |
| CS_EXTENSIONS_DIR= "/root/.code-server/extensions" | |
| CS_WORKSPACE = "/root/.openclaw" | |
| CS_LOG = "/root/.openclaw/logs/code-server.log" | |
| IDLE_MINUTES = int(os.environ.get("IDE_IDLE_MINUTES", "30")) | |
| LAST_ACCESS_FILE = "/tmp/cs-last-access" | |
| CS_PID_FILE = "/tmp/cs-server.pid" | |
| CHECK_INTERVAL = 60 # 秒 | |
| # ── 全局狀態 ────────────────────────────────────────────────────────────────── | |
| _lock = threading.Lock() | |
| _starting = False # 正在啟動中,防止並發重複啟動 | |
| # ── 工具函數 ────────────────────────────────────────────────────────────────── | |
| def log(msg): | |
| ts = time.strftime("%Y-%m-%d %H:%M:%S") | |
| print(f"[cs-manager {ts}] {msg}", flush=True) | |
| def touch_last_access(): | |
| try: | |
| with open(LAST_ACCESS_FILE, "w") as f: | |
| f.write(str(time.time())) | |
| os.utime(LAST_ACCESS_FILE, None) | |
| except Exception as e: | |
| log(f"touch_last_access error: {e}") | |
| def get_cs_pid(): | |
| """讀取 PID 文件,返回 code-server PID(若進程存在),否則返回 None""" | |
| try: | |
| with open(CS_PID_FILE) as f: | |
| pid = int(f.read().strip()) | |
| os.kill(pid, 0) # 探測進程是否存在 | |
| return pid | |
| except Exception: | |
| return None | |
| def is_cs_running(): | |
| return get_cs_pid() is not None | |
| def is_cs_port_ready(): | |
| """嘗試 TCP 連接 code-server 端口,確認服務真正可用""" | |
| try: | |
| s = socket.create_connection(("127.0.0.1", CS_PORT), timeout=1) | |
| s.close() | |
| return True | |
| except Exception: | |
| return False | |
| def start_cs(): | |
| """啟動 code-server,非阻塞,返回後進程在後台運行""" | |
| global _starting | |
| with _lock: | |
| if _starting or is_cs_running(): | |
| return | |
| _starting = True | |
| try: | |
| log(f"Starting code-server on port {CS_PORT}...") | |
| os.makedirs(os.path.dirname(CS_LOG), exist_ok=True) | |
| os.makedirs(CS_USER_DATA_DIR, exist_ok=True) | |
| # 寫入 code-server config.yaml | |
| cfg_dir = "/root/.config/code-server" | |
| os.makedirs(cfg_dir, exist_ok=True) | |
| with open(os.path.join(cfg_dir, "config.yaml"), "w") as f: | |
| f.write(f"bind-addr: 127.0.0.1:{CS_PORT}\n") | |
| f.write(f"auth: password\n") | |
| f.write(f"password: {CS_PASSWORD}\n") | |
| f.write(f"cert: false\n") | |
| env = os.environ.copy() | |
| env.pop("PORT", None) # 防止 code-server 讀取 PORT=7860 覆蓋端口 | |
| log_file = open(CS_LOG, "a") | |
| proc = subprocess.Popen( | |
| [ | |
| "code-server", | |
| "--disable-telemetry", | |
| "--disable-update-check", | |
| f"--user-data-dir={CS_USER_DATA_DIR}", | |
| f"--extensions-dir={CS_EXTENSIONS_DIR}", | |
| CS_WORKSPACE, | |
| ], | |
| stdout=log_file, | |
| stderr=log_file, | |
| env=env, | |
| start_new_session=True, # 脫離當前進程組,避免隨父進程終止 | |
| ) | |
| # 寫入 PID 文件 | |
| with open(CS_PID_FILE, "w") as f: | |
| f.write(str(proc.pid)) | |
| log(f"code-server started, PID={proc.pid}") | |
| touch_last_access() | |
| except Exception as e: | |
| log(f"start_cs error: {e}") | |
| finally: | |
| with _lock: | |
| _starting = False | |
| def stop_cs(): | |
| """終止 code-server 進程""" | |
| pid = get_cs_pid() | |
| if pid is None: | |
| log("stop_cs: code-server not running, skip") | |
| return | |
| try: | |
| os.kill(pid, signal.SIGTERM) | |
| log(f"code-server (PID={pid}) terminated (SIGTERM)") | |
| except Exception as e: | |
| log(f"stop_cs error: {e}") | |
| try: | |
| os.remove(CS_PID_FILE) | |
| except Exception: | |
| pass | |
| # ── 閒置檢測定時器 ──────────────────────────────────────────────────────────── | |
| def idle_checker(): | |
| while True: | |
| time.sleep(CHECK_INTERVAL) | |
| try: | |
| if not is_cs_running(): | |
| continue | |
| try: | |
| mtime = os.path.getmtime(LAST_ACCESS_FILE) | |
| except FileNotFoundError: | |
| mtime = 0 | |
| idle_secs = time.time() - mtime | |
| idle_mins = idle_secs / 60 | |
| if idle_mins >= IDLE_MINUTES: | |
| log(f"Idle for {idle_mins:.1f} min (threshold={IDLE_MINUTES} min), stopping code-server...") | |
| stop_cs() | |
| else: | |
| remaining = IDLE_MINUTES - idle_mins | |
| log(f"code-server running, idle {idle_mins:.1f}/{IDLE_MINUTES} min (auto-stop in {remaining:.1f} min)") | |
| except Exception as e: | |
| log(f"idle_checker error: {e}") | |
| # ── HTTP 請求處理 ───────────────────────────────────────────────────────────── | |
| WAKEUP_HTML = """\ | |
| <!DOCTYPE html> | |
| <html lang="zh"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta http-equiv="refresh" content="5;url=/ide/"> | |
| <title>IDE 啟動中...</title> | |
| <style> | |
| body {{ font-family: sans-serif; display:flex; align-items:center; | |
| justify-content:center; height:100vh; margin:0; background:#1e1e1e; color:#ccc; }} | |
| .box {{ text-align:center; }} | |
| .spinner {{ width:48px; height:48px; border:5px solid #555; | |
| border-top-color:#0078d4; border-radius:50%; | |
| animation:spin 1s linear infinite; margin:0 auto 20px; }} | |
| @keyframes spin {{ to {{ transform:rotate(360deg) }} }} | |
| p {{ margin:6px 0; }} | |
| small {{ color:#888; }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="box"> | |
| <div class="spinner"></div> | |
| <p>VS Code IDE 正在啟動,請稍候...</p> | |
| <p><small>頁面將在 5 秒後自動重試,或手動 <a href="/ide/" style="color:#0078d4">刷新</a></small></p> | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| class CSManagerHandler(BaseHTTPRequestHandler): | |
| def log_message(self, fmt, *args): | |
| pass # 靜默 access log,避免刷日誌 | |
| def do_GET(self): | |
| if self.path.startswith("/wakeup"): | |
| self._handle_wakeup() | |
| elif self.path.startswith("/heartbeat"): | |
| self._handle_heartbeat() | |
| else: | |
| self.send_response(404) | |
| self.end_headers() | |
| def _handle_wakeup(self): | |
| """ | |
| nginx @ide_wakeup 調用此端點。 | |
| 若 code-server 已在運行(端口可達),返回 302 重定向回 /ide/。 | |
| 若未運行,觸發後台啟動,返回 200 "啟動中"等待頁。 | |
| """ | |
| if is_cs_port_ready(): | |
| # code-server 已就緒,302 重定向回 /ide/ | |
| touch_last_access() | |
| self.send_response(302) | |
| self.send_header("Location", "/ide/") | |
| self.end_headers() | |
| else: | |
| # 觸發啟動(後台),返回等待頁 | |
| if not is_cs_running(): | |
| t = threading.Thread(target=start_cs, daemon=True) | |
| t.start() | |
| html = WAKEUP_HTML.encode() | |
| self.send_response(200) | |
| self.send_header("Content-Type", "text/html; charset=utf-8") | |
| self.send_header("Content-Length", str(len(html))) | |
| self.end_headers() | |
| self.wfile.write(html) | |
| def _handle_heartbeat(self): | |
| """nginx 成功代理 /ide/ 請求後調用,更新活躍時間""" | |
| touch_last_access() | |
| self.send_response(204) | |
| self.end_headers() | |
| class UnixSocketHTTPServer(HTTPServer): | |
| """在 Unix domain socket 上監聽的 HTTPServer""" | |
| address_family = socket.AF_UNIX | |
| def server_bind(self): | |
| try: | |
| os.unlink(self.server_address) | |
| except FileNotFoundError: | |
| pass | |
| super().server_bind() | |
| os.chmod(self.server_address, 0o666) | |
| # ── 主入口 ──────────────────────────────────────────────────────────────────── | |
| if __name__ == "__main__": | |
| log(f"cs-manager starting (idle_timeout={IDLE_MINUTES} min, cs_port={CS_PORT})") | |
| log(f"Listening on Unix socket: {SOCK_PATH}") | |
| # 啟動閒置檢測後台線程 | |
| t = threading.Thread(target=idle_checker, daemon=True) | |
| t.start() | |
| # 啟動 HTTP 服務(Unix socket) | |
| server = UnixSocketHTTPServer(SOCK_PATH, CSManagerHandler) | |
| try: | |
| server.serve_forever() | |
| except KeyboardInterrupt: | |
| log("cs-manager shutting down") | |
| server.server_close() | |
| PYEOF | |
| RUN chmod +x /usr/local/bin/cs-manager | |
| # ── 12b. 寫入主啟動腳本 ────────────────────────────────────────────────────── | |
| RUN cat <<'EOF' > /usr/local/bin/start-openclaw-code-server | |
| #!/bin/bash | |
| set -e | |
| # HuggingFace Space 對外端口(由 HF 平台注入,默認 7860) | |
| LISTEN_PORT="${PORT:-7860}" | |
| # openclaw gateway 內部端口(避免與 nginx 對外端口衝突) | |
| GATEWAY_PORT=7862 | |
| # code-server 內部端口 | |
| CODE_SERVER_PORT="${CODE_SERVER_PORT:-13337}" | |
| # code-server 閒置自動關閉時間(分鐘),默認 30 分鐘 | |
| IDE_IDLE_MINUTES="${IDE_IDLE_MINUTES:-30}" | |
| export CODE_SERVER_PORT IDE_IDLE_MINUTES | |
| export CODE_SERVER_PASSWORD="${CODE_SERVER_PASSWORD:-changeme123!}" | |
| echo "=== start-openclaw-code-server (按需啟停模式) ===" | |
| echo "External listen port : ${LISTEN_PORT}" | |
| echo "Gateway internal port : ${GATEWAY_PORT}" | |
| echo "code-server internal port: ${CODE_SERVER_PORT}" | |
| echo "IDE idle auto-stop : ${IDE_IDLE_MINUTES} min" | |
| echo "code-server starts on first /ide/ visit (NOT at boot)" | |
| # ── 1. 後台啟動原版 start-openclaw(覆蓋 PORT 使 gateway 監聽內部端口)────── | |
| # start-openclaw 內所有邏輯(恢復備份、寫配置、後台備份循環、微信登錄、設備配對審批等) | |
| # 全部照常執行,僅 gateway 監聽端口從 $PORT 改為內部 $GATEWAY_PORT | |
| mkdir -p /root/.openclaw/logs | |
| PORT=${GATEWAY_PORT} bash /usr/local/bin/start-openclaw 2>&1 | tee /root/.openclaw/logs/openclaw-main.log & | |
| echo "start-openclaw launched in background (gateway port=${GATEWAY_PORT})" | |
| # ── 2. 啟動 cs-manager 守護進程(後台)─────────────────────────────────────── | |
| # cs-manager 監聽 Unix socket,負責 code-server 的按需啟動和閒置關閉 | |
| python3 /usr/local/bin/cs-manager 2>&1 | tee /root/.openclaw/logs/cs-manager.log & | |
| echo "cs-manager launched in background" | |
| # 等待 cs-manager socket 就緒(最多 10 秒) | |
| for i in $(seq 1 20); do | |
| if [ -S /tmp/cs-manager.sock ]; then | |
| echo "cs-manager socket ready" | |
| break | |
| fi | |
| sleep 0.5 | |
| done | |
| # ── 3. 等待 gateway 內部就緒(最多 120 秒)─────────────────────────────────── | |
| echo "Waiting for openclaw gateway on internal port ${GATEWAY_PORT}..." | |
| for i in $(seq 1 60); do | |
| if curl -fsS http://127.0.0.1:${GATEWAY_PORT}/ >/dev/null 2>&1; then | |
| echo "Gateway is up after $((i * 2))s." | |
| break | |
| fi | |
| sleep 2 | |
| done | |
| # ── 4. 生成 nginx 配置並啟動 ───────────────────────────────────────────────── | |
| # | |
| # 按需啟停工作原理: | |
| # | |
| # [正常訪問 /ide/(code-server 已運行)] | |
| # 瀏覽器 → nginx /ide/ → proxy_pass code-server:13337(直接成功) | |
| # → post_action /ide-heartbeat/ → cs-manager /heartbeat(更新時間戳) | |
| # | |
| # [首次訪問 /ide/(code-server 未運行)] | |
| # 瀏覽器 → nginx /ide/ → proxy_pass code-server:13337(連接失敗,502) | |
| # → error_page 502 504 @ide_wakeup | |
| # → nginx @ide_wakeup → proxy_pass cs-manager /wakeup(通過 unix socket) | |
| # → cs-manager 後台啟動 code-server,返回"啟動中"HTML(5s 自動刷新) | |
| # 5秒後瀏覽器自動重試 /ide/ → 若 code-server 已就緒則正常進入 | |
| # | |
| # [閒置超時自動關閉] | |
| # cs-manager 後台每 60s 檢查 /tmp/cs-last-access 的 mtime | |
| # 若超過 IDE_IDLE_MINUTES 分鐘未更新,SIGTERM 殺掉 code-server 進程 | |
| # | |
| # nginx 配置注意: | |
| # nginx 配置文件不支持 bash 變量語法(${}), | |
| # 所有動態端口值通過 sed 替換佔位符在運行時寫入。 | |
| # nginx 嚴格禁止在命名 location(@xxx)的 proxy_pass 中帶 URI 路徑部分, | |
| # 使用 rewrite 指令解決(rewrite ^ /target break; proxy_pass http://upstream;)。 | |
| rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf | |
| cat > /etc/nginx/conf.d/openclaw-ide.conf <<NGINX | |
| # cs-manager Unix socket upstream(按需啟停控制器) | |
| upstream cs_manager { | |
| server unix:/tmp/cs-manager.sock; | |
| } | |
| server { | |
| listen PLACEHOLDER_LISTEN_PORT; | |
| server_name _; | |
| client_max_body_size 100M; | |
| access_log /dev/stdout; | |
| error_log /dev/stderr warn; | |
| # 關鍵:Cloudflare 做 SSL 終結,nginx 只收到 http 請求。 | |
| # 若 nginx 生成絕對 URL(如 301/302 Location),會帶上 http://host:PORT, | |
| # 導致瀏覽器被重定向到帶明確端口的 http URL,被 Cloudflare 拒絕(400 Bad Request)。 | |
| absolute_redirect off; | |
| port_in_redirect off; | |
| # ── /ide/ 主 location(按需啟停核心)──────────────────────────────────── | |
| # proxy_connect_timeout 設為 2s:code-server 未運行時快速失敗觸發 error_page | |
| # post_action:每次成功代理後向 cs-manager 發心跳,更新活躍時間戳 | |
| location /ide/ { | |
| proxy_pass http://127.0.0.1:PLACEHOLDER_CODE_SERVER_PORT/; | |
| proxy_http_version 1.1; | |
| proxy_set_header Host \$host; | |
| proxy_set_header Upgrade \$http_upgrade; | |
| proxy_set_header Connection "upgrade"; | |
| proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; | |
| proxy_set_header X-Forwarded-Proto \$scheme; | |
| proxy_redirect / /ide/; | |
| proxy_read_timeout 86400; | |
| # 快速檢測 code-server 是否在線(2s 即失敗,觸發 error_page) | |
| proxy_connect_timeout 2s; | |
| # code-server 未運行時,502/504 觸發喚醒流程 | |
| error_page 502 504 @ide_wakeup; | |
| # 成功代理後,後台子請求更新心跳時間戳 | |
| post_action /ide-heartbeat/; | |
| } | |
| # ── 心跳端點(供 post_action 調用,更新活躍時間)──────────────────────── | |
| # internal 指令確保此 location 只能由 nginx 內部請求訪問,外部瀏覽器無法直接訪問 | |
| # rewrite 將路徑改寫為 /heartbeat,再用不帶路徑的 proxy_pass(nginx 硬限制) | |
| location /ide-heartbeat/ { | |
| internal; | |
| rewrite ^ /heartbeat break; | |
| proxy_pass http://cs_manager; | |
| proxy_connect_timeout 1s; | |
| proxy_read_timeout 2s; | |
| } | |
| # ── 喚醒 location(code-server 未運行時的 fallback)──────────────────── | |
| # 命名 location(@前綴)只能由 error_page 跳轉,不能直接 URL 訪問 | |
| # rewrite 將路徑改寫為 /wakeup,再用不帶路徑的 proxy_pass(nginx 硬限制) | |
| location @ide_wakeup { | |
| rewrite ^ /wakeup break; | |
| proxy_pass http://cs_manager; | |
| proxy_http_version 1.1; | |
| proxy_set_header Host \$host; | |
| proxy_connect_timeout 5s; | |
| proxy_read_timeout 30s; | |
| } | |
| # ── 其餘請求:代理到 openclaw gateway ──────────────────────────────────── | |
| location / { | |
| proxy_pass http://127.0.0.1:PLACEHOLDER_GATEWAY_PORT/; | |
| proxy_http_version 1.1; | |
| proxy_set_header Host \$host; | |
| proxy_set_header Upgrade \$http_upgrade; | |
| proxy_set_header Connection "upgrade"; | |
| proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; | |
| proxy_set_header X-Forwarded-Proto \$scheme; | |
| proxy_read_timeout 86400; | |
| } | |
| } | |
| NGINX | |
| sed -i \ | |
| "s/PLACEHOLDER_LISTEN_PORT/${LISTEN_PORT}/g; | |
| s/PLACEHOLDER_CODE_SERVER_PORT/${CODE_SERVER_PORT}/g; | |
| s/PLACEHOLDER_GATEWAY_PORT/${GATEWAY_PORT}/g" \ | |
| /etc/nginx/conf.d/openclaw-ide.conf | |
| echo "nginx config:" | |
| cat /etc/nginx/conf.d/openclaw-ide.conf | |
| nginx -t | |
| echo "Starting nginx (foreground)..." | |
| exec nginx -g 'daemon off; error_log /dev/stderr warn;' | |
| EOF | |
| RUN chmod +x /usr/local/bin/start-openclaw-code-server | |
| EXPOSE 7860 | |
| # 訪問方式(Space 啟動後): | |
| # 主界面(OpenClaw): https://<your-space>.hf.space/ | |
| # IDE(VS Code) : https://<your-space>.hf.space/ide/ | |
| # 首次訪問時自動啟動 code-server(約 10~20 秒) | |
| # 密碼由環境變量 CODE_SERVER_PASSWORD 控制,默認:changeme123! | |
| # 閒置 IDE_IDLE_MINUTES 分鐘(默認 30)無訪問後自動關閉 | |
| # | |
| # 環境變量: | |
| # CODE_SERVER_PASSWORD IDE 登錄密碼(建議在 HF Space Secrets 中設置) | |
| # IDE_IDLE_MINUTES IDE 閒置自動關閉時間(分鐘),默認 30 | |
| CMD ["/usr/local/bin/start-openclaw-code-server"] |