heiyuheiyu commited on
Commit
26b4ed0
·
verified ·
1 Parent(s): 3a1c12e

Upload Dockerfile

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