# 核心鏡像選擇 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 </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 = """\ IDE 啟動中...

VS Code IDE 正在啟動,請稍候...

頁面將在 5 秒後自動重試,或手動 刷新

""" 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 <.hf.space/ # IDE(VS Code) : https://.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"]