test2 / Dockerfile
heiyuheiyu's picture
Update Dockerfile
a5deffc verified
# 核心鏡像選擇
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"]