openhands commited on
Commit
0e6fb59
·
1 Parent(s): d65efac

Fix prune_remote_deleted_files guard and health probe status threshold

Browse files

- Add try/except around prune_remote_deleted_files to handle older
huggingface_hub versions where delete_files() may not exist
- Revert health probe statusCode threshold from <400 to <500 so 5xx
errors are correctly reported as unhealthy
- Add Jupyter DevData sync script for separate dataset backup
- Update start.sh with Jupyter terminal startup logic

Files changed (4) hide show
  1. health-server.js +64 -12
  2. jupyter-devdata-sync.py +152 -0
  3. openclaw-sync.py +4 -1
  4. start.sh +157 -14
health-server.js CHANGED
@@ -3,24 +3,39 @@ const http = require("http");
3
  const fs = require("fs");
4
  const net = require("net");
5
 
6
- const PORT = 7861;
7
- const GATEWAY_PORT = 7860;
 
 
 
 
 
 
 
 
 
 
8
  const GATEWAY_HOST = "127.0.0.1";
9
- const JUPYTER_PORT = 8888;
10
  const JUPYTER_HOST = "127.0.0.1";
11
- const JUPYTER_BASE = "/terminal";
12
- const DEV_MODE_ENABLED = /^(true|1|yes|on)$/i.test(process.env.DEV_MODE || "");
13
  const JUPYTER_ENABLED = /^(true|1|yes|on)$/i.test(
14
  process.env.HUGGINGCLAW_JUPYTER_ENABLED || (DEV_MODE_ENABLED ? "true" : "false")
15
  );
16
  const startTime = Date.now();
17
  const LLM_MODEL = process.env.LLM_MODEL || "Not Set";
18
  const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN;
19
- const WHATSAPP_ENABLED = /^true$/i.test(process.env.WHATSAPP_ENABLED || "");
20
  const WHATSAPP_STATUS_FILE = "/tmp/huggingclaw-wa-status.json";
21
  const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN;
22
- const SYNC_INTERVAL = process.env.SYNC_INTERVAL || "180";
23
- const APP_BASE = "/app";
 
 
 
 
 
24
  const SYNC_STATUS_FILE = "/tmp/sync-status.json";
25
  const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
26
  "/tmp/huggingclaw-cloudflare-keepalive-status.json";
@@ -113,6 +128,13 @@ function renderDashboard(data) {
113
 
114
  if (JUPYTER_ENABLED) {
115
  tiles.push(tile({ title: "Terminal", value: badge(data.jupyterReady ? "Online" : "Starting…", data.jupyterReady ? "ok" : "warn"), detail: `JupyterLab at <a href="${JUPYTER_BASE}/" style="color:inherit">${JUPYTER_BASE}/</a>`, tone: data.jupyterReady ? "ok" : "warn" }));
 
 
 
 
 
 
 
116
  }
117
 
118
  const tilesHtml = tiles.join("");
@@ -128,7 +150,7 @@ function renderDashboard(data) {
128
  .subtitle{margin-top:12px;color:var(--muted);font-size:.72rem;text-transform:uppercase;letter-spacing:.14em;font-weight:800}
129
  .btn-row{display:flex;gap:12px;margin:24px 0 20px}
130
  .hero-action{display:flex;flex:1;min-height:46px;align-items:center;justify-content:center;border-radius:8px;background:#fff;color:#000;text-decoration:none;font-weight:850;font-size:.98rem;transition:opacity .15s}
131
- .hero-action:hover{opacity:.9}.hero-action.terminal{background:#1e1e2e;color:#cdd6f4;border:1px solid #45475a}
132
  .overview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:10px}
133
  .tile{border:1px solid var(--line);background:var(--panel);border-radius:11px;padding:18px;min-height:124px;display:flex;flex-direction:column;gap:10px}
134
  .tile.ok{border-color:rgba(34,197,94,.22)}.tile.warn{border-color:rgba(245,197,66,.24)}.tile.off{border-color:rgba(251,113,133,.28)}
@@ -149,16 +171,41 @@ function renderDashboard(data) {
149
  </style></head><body><main>
150
  <header><h1>🦞 HuggingClaw</h1><div class="subtitle">OpenClaw Gateway</div></header>
151
  <div class="btn-row">
152
- <a class="hero-action" href="${APP_BASE}/">Open Control UI →</a>
153
- ${JUPYTER_ENABLED ? `<a class="hero-action terminal" href="${JUPYTER_BASE}/">💻 Open Terminal →</a>` : ""}
 
154
  </div>
155
  <section class="overview">${tilesHtml}</section>
156
  <footer>Built by <a href="https://github.com/somratpro" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:none">@somratpro</a>${JUPYTER_ENABLED ? " · Terminal by JupyterLab" : ""}<br><span>Public Spaces can be opened directly via <code>.hf.space</code>; private Spaces require the App tab session.</span></footer>
157
  </main>
158
- <script>document.querySelectorAll('.local-time').forEach(el=>{const d=new Date(el.getAttribute('data-iso'));if(!isNaN(d))el.textContent='At '+d.toLocaleTimeString()});</script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  </body></html>`;
160
  }
161
 
 
 
 
 
 
 
 
 
162
  // ── Generic proxy ──
163
  function proxiedPath(url, { stripPrefix = "" } = {}) {
164
  if (!stripPrefix) return url.pathname + url.search;
@@ -261,6 +308,11 @@ const server = http.createServer(async (req, res) => {
261
  return res.end(JSON.stringify({ model: LLM_MODEL, uptime: formatUptime(Date.now() - startTime), gatewayReady, jupyterReady, sync: getSyncStatus(), whatsapp: readGuardianStatus(), keepalive: getKeepaliveStatus() }));
262
  }
263
 
 
 
 
 
 
264
  if (pathname === "/" || pathname === "/dashboard") {
265
  const [gatewayReady, jupyterReady] = await Promise.all([
266
  probePort(GATEWAY_HOST, GATEWAY_PORT, "/health"),
 
3
  const fs = require("fs");
4
  const net = require("net");
5
 
6
+ function isTrue(value) {
7
+ return /^(true|1|yes|on)$/i.test(String(value || "").trim());
8
+ }
9
+ function normalizeBase(value, fallback) {
10
+ const raw = String(value || fallback || "").trim() || fallback;
11
+ if (!raw) return fallback;
12
+ const base = raw.startsWith("/") ? raw : `/${raw}`;
13
+ return base.replace(/\/+$/, "") || fallback;
14
+ }
15
+
16
+ const PORT = Number.parseInt(process.env.PORT || "7861", 10);
17
+ const GATEWAY_PORT = Number.parseInt(process.env.GATEWAY_PORT || "7860", 10);
18
  const GATEWAY_HOST = "127.0.0.1";
19
+ const JUPYTER_PORT = Number.parseInt(process.env.JUPYTER_PORT || "8888", 10);
20
  const JUPYTER_HOST = "127.0.0.1";
21
+ const JUPYTER_BASE = normalizeBase(process.env.JUPYTER_BASE, "/terminal");
22
+ const DEV_MODE_ENABLED = isTrue(process.env.DEV_MODE);
23
  const JUPYTER_ENABLED = /^(true|1|yes|on)$/i.test(
24
  process.env.HUGGINGCLAW_JUPYTER_ENABLED || (DEV_MODE_ENABLED ? "true" : "false")
25
  );
26
  const startTime = Date.now();
27
  const LLM_MODEL = process.env.LLM_MODEL || "Not Set";
28
  const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN;
29
+ const WHATSAPP_ENABLED = isTrue(process.env.WHATSAPP_ENABLED);
30
  const WHATSAPP_STATUS_FILE = "/tmp/huggingclaw-wa-status.json";
31
  const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN;
32
+ const SYNC_INTERVAL = (process.env.SYNC_INTERVAL || "180").trim() || "180";
33
+ const BACKUP_DATASET_NAME = (process.env.BACKUP_DATASET_NAME || process.env.BACKUP_DATASET || "huggingclaw-backup").trim() || "huggingclaw-backup";
34
+ const DEVDATA_DATASET_NAME = (process.env.DEVDATA_DATASET_NAME || "huggingclaw-devdata").trim() || "huggingclaw-devdata";
35
+ const DEVDATA_SYNC_INTERVAL = (process.env.DEVDATA_SYNC_INTERVAL || "180").trim() || "180";
36
+ const DEVDATA_SEPARATE_DATASET = DEVDATA_DATASET_NAME !== BACKUP_DATASET_NAME;
37
+ const DEVDATA_ENABLED = JUPYTER_ENABLED && HF_BACKUP_ENABLED && DEVDATA_SEPARATE_DATASET && !/^(off|false|0|no)$/i.test((process.env.DEVDATA || "on").trim());
38
+ const APP_BASE = normalizeBase(process.env.APP_BASE, "/app");
39
  const SYNC_STATUS_FILE = "/tmp/sync-status.json";
40
  const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
41
  "/tmp/huggingclaw-cloudflare-keepalive-status.json";
 
128
 
129
  if (JUPYTER_ENABLED) {
130
  tiles.push(tile({ title: "Terminal", value: badge(data.jupyterReady ? "Online" : "Starting…", data.jupyterReady ? "ok" : "warn"), detail: `JupyterLab at <a href="${JUPYTER_BASE}/" style="color:inherit">${JUPYTER_BASE}/</a>`, tone: data.jupyterReady ? "ok" : "warn" }));
131
+ tiles.push(tile({
132
+ title: "DevData",
133
+ value: badge(DEVDATA_ENABLED ? "Enabled" : "Disabled", DEVDATA_ENABLED ? "ok" : "neutral"),
134
+ detail: DEVDATA_ENABLED ? `Separate dataset <code>${escapeHtml(DEVDATA_DATASET_NAME)}</code>` : DEVDATA_SEPARATE_DATASET ? "Separate Jupyter dataset backup inactive" : "DevData dataset must be separate from main backup dataset",
135
+ tone: DEVDATA_ENABLED ? "ok" : "neutral",
136
+ meta: `Sync interval ${escapeHtml(DEVDATA_SYNC_INTERVAL)}s`,
137
+ }));
138
  }
139
 
140
  const tilesHtml = tiles.join("");
 
150
  .subtitle{margin-top:12px;color:var(--muted);font-size:.72rem;text-transform:uppercase;letter-spacing:.14em;font-weight:800}
151
  .btn-row{display:flex;gap:12px;margin:24px 0 20px}
152
  .hero-action{display:flex;flex:1;min-height:46px;align-items:center;justify-content:center;border-radius:8px;background:#fff;color:#000;text-decoration:none;font-weight:850;font-size:.98rem;transition:opacity .15s}
153
+ .hero-action:hover{opacity:.9}.hero-action.terminal{background:#1e1e2e;color:#cdd6f4;border:1px solid #45475a}.hero-action.env{background:#312e81;color:#eef2ff;border:1px solid #6366f1}
154
  .overview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:10px}
155
  .tile{border:1px solid var(--line);background:var(--panel);border-radius:11px;padding:18px;min-height:124px;display:flex;flex-direction:column;gap:10px}
156
  .tile.ok{border-color:rgba(34,197,94,.22)}.tile.warn{border-color:rgba(245,197,66,.24)}.tile.off{border-color:rgba(251,113,133,.28)}
 
171
  </style></head><body><main>
172
  <header><h1>🦞 HuggingClaw</h1><div class="subtitle">OpenClaw Gateway</div></header>
173
  <div class="btn-row">
174
+ <a class="hero-action" data-space-link="app" href="${APP_BASE}/">Open Control UI →</a>
175
+ ${JUPYTER_ENABLED ? `<a class="hero-action terminal" data-space-link="terminal" href="${JUPYTER_BASE}/">💻 Open Terminal →</a>` : ""}
176
+ <a class="hero-action env" data-space-link="env-builder" href="/env-builder">⚙️ Env Builder →</a>
177
  </div>
178
  <section class="overview">${tilesHtml}</section>
179
  <footer>Built by <a href="https://github.com/somratpro" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:none">@somratpro</a>${JUPYTER_ENABLED ? " · Terminal by JupyterLab" : ""}<br><span>Public Spaces can be opened directly via <code>.hf.space</code>; private Spaces require the App tab session.</span></footer>
180
  </main>
181
+ <script>
182
+ document.querySelectorAll('.local-time').forEach(el=>{const d=new Date(el.getAttribute('data-iso'));if(!isNaN(d))el.textContent='At '+d.toLocaleTimeString()});
183
+ const inEmbeddedApp = (() => { try { return window.top !== window.self; } catch { return true; } })();
184
+ const isDirectHfSpaceHost = /\.hf\.space$/i.test(window.location.hostname);
185
+ // If inside the HF App iframe, force new-tab navigation so users can break out
186
+ // to the standalone Space host. Also keep direct .hf.space behavior opening new tabs.
187
+ const openInNewTab = inEmbeddedApp || isDirectHfSpaceHost;
188
+ document.querySelectorAll('a[data-space-link]').forEach((a) => {
189
+ if (openInNewTab) {
190
+ a.setAttribute('target', '_blank');
191
+ a.setAttribute('rel', 'noopener noreferrer');
192
+ } else {
193
+ a.removeAttribute('target');
194
+ a.removeAttribute('rel');
195
+ }
196
+ });
197
+ </script>
198
  </body></html>`;
199
  }
200
 
201
+ function renderEnvBuilder() {
202
+ try {
203
+ return fs.readFileSync(require("path").join(__dirname, "env-builder.html"), "utf8");
204
+ } catch (exc) {
205
+ return `<!doctype html><title>Env Builder unavailable</title><pre>${escapeHtml(exc.message)}</pre>`;
206
+ }
207
+ }
208
+
209
  // ── Generic proxy ──
210
  function proxiedPath(url, { stripPrefix = "" } = {}) {
211
  if (!stripPrefix) return url.pathname + url.search;
 
308
  return res.end(JSON.stringify({ model: LLM_MODEL, uptime: formatUptime(Date.now() - startTime), gatewayReady, jupyterReady, sync: getSyncStatus(), whatsapp: readGuardianStatus(), keepalive: getKeepaliveStatus() }));
309
  }
310
 
311
+ if (pathname === "/env-builder" || pathname === "/env-builder/") {
312
+ res.writeHead(200, { "Content-Type": "text/html" });
313
+ return res.end(renderEnvBuilder());
314
+ }
315
+
316
  if (pathname === "/" || pathname === "/dashboard") {
317
  const [gatewayReady, jupyterReady] = await Promise.all([
318
  probePort(GATEWAY_HOST, GATEWAY_PORT, "/health"),
jupyter-devdata-sync.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import os, shutil, tempfile, time
5
+ from pathlib import Path
6
+
7
+ HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
8
+ HF_USERNAME = os.environ.get("HF_USERNAME", "").strip() or os.environ.get("SPACE_AUTHOR_NAME", "").strip()
9
+ DATASET_NAME = os.environ.get("DEVDATA_DATASET_NAME", "").strip() or "huggingclaw-devdata"
10
+ BACKUP_DATASET_NAME = os.environ.get("BACKUP_DATASET_NAME", "").strip() or os.environ.get("BACKUP_DATASET", "").strip() or "huggingclaw-backup"
11
+ JUPYTER_ROOT = Path(os.environ.get("JUPYTER_ROOT_DIR", "/home/node")).resolve()
12
+ INTERVAL = int((os.environ.get("DEVDATA_SYNC_INTERVAL", "").strip() or "180"))
13
+ def is_true(value):
14
+ return str(value).strip().lower() in {"1", "true", "yes", "on"}
15
+
16
+ ENABLE = is_true(os.environ.get("DEVDATA", "on"))
17
+
18
+
19
+ def classify_error(exc: Exception) -> str:
20
+ msg = str(exc).lower()
21
+ if isinstance(exc, PermissionError) or "permission denied" in msg:
22
+ return "filesystem-permission"
23
+ if any(k in msg for k in ("connection error", "fetch failed", "timeout", "temporarily unavailable", "network")):
24
+ return "network-provider"
25
+ if "unsafe" in msg or "malware" in msg or "security" in msg:
26
+ return "safety-scan"
27
+ return "general"
28
+
29
+ EXCLUDE = {
30
+ ".cache",
31
+ "node_modules",
32
+ ".npm",
33
+ ".yarn",
34
+ ".local/share/Trash",
35
+ ".ipynb_checkpoints",
36
+ ".openclaw",
37
+ "app",
38
+ "HuggingClaw",
39
+ "HuggingClaw-Workspace",
40
+ "browser-deps",
41
+ }
42
+
43
+
44
+ def enabled():
45
+ dev = is_true(os.environ.get("DEV_MODE", ""))
46
+ separate_dataset = DATASET_NAME != BACKUP_DATASET_NAME
47
+ if ENABLE and dev and HF_TOKEN and not separate_dataset:
48
+ print("DevData sync disabled: DEVDATA_DATASET_NAME must be separate from BACKUP_DATASET_NAME.")
49
+ return ENABLE and dev and bool(HF_TOKEN) and separate_dataset
50
+
51
+ def validate_jupyter_paths() -> None:
52
+ # JupyterLab theme/settings live under ~/.jupyter and ~/.local/share/jupyter.
53
+ # If these are not writable, settings can appear to "reset" every restart.
54
+ for required in (JUPYTER_ROOT, Path("/home/node/.jupyter"), Path("/home/node/.local/share/jupyter")):
55
+ try:
56
+ required.mkdir(parents=True, exist_ok=True)
57
+ probe = required / ".devdata-write-check"
58
+ probe.write_text("ok", encoding="utf-8")
59
+ probe.unlink(missing_ok=True)
60
+ except Exception as exc:
61
+ kind = classify_error(exc)
62
+ print(f"DevData warning [{kind}]: {required} is not writable; Jupyter settings may not persist ({exc})")
63
+
64
+ def repo_id(api) -> str:
65
+ ns = HF_USERNAME
66
+ if not ns:
67
+ who = api.whoami()
68
+ ns = who.get("name") or who.get("user") or ""
69
+ if not ns:
70
+ raise RuntimeError("Cannot resolve HF namespace for devdata sync")
71
+ return f"{ns}/{DATASET_NAME}"
72
+
73
+ def should_skip(p: Path):
74
+ parts = set(p.parts)
75
+ return any(x in parts for x in EXCLUDE)
76
+
77
+ def snapshot(src: Path, dst: Path):
78
+ for p in src.rglob("*"):
79
+ rel = p.relative_to(src)
80
+ if should_skip(rel):
81
+ continue
82
+ if p.is_symlink():
83
+ continue
84
+ target = dst / rel
85
+ if p.is_dir():
86
+ target.mkdir(parents=True, exist_ok=True)
87
+ elif p.is_file():
88
+ target.parent.mkdir(parents=True, exist_ok=True)
89
+ try:
90
+ shutil.copy2(p, target)
91
+ except OSError:
92
+ pass
93
+
94
+ def restore_once(api: HfApi, rid: str):
95
+ tmp = Path(tempfile.mkdtemp(prefix="devdata-restore-"))
96
+ try:
97
+ snapshot_download(repo_id=rid, repo_type="dataset", local_dir=str(tmp), local_dir_use_symlinks=False, token=HF_TOKEN)
98
+ for p in tmp.rglob("*"):
99
+ rel = p.relative_to(tmp)
100
+ if should_skip(rel):
101
+ continue
102
+ target = JUPYTER_ROOT / rel
103
+ if p.is_dir():
104
+ target.mkdir(parents=True, exist_ok=True)
105
+ elif p.is_file():
106
+ target.parent.mkdir(parents=True, exist_ok=True)
107
+ try:
108
+ shutil.copy2(p, target)
109
+ except OSError as exc:
110
+ kind = classify_error(exc)
111
+ print(f"DevData restore skip [{kind}] (cannot write {target}): {exc}")
112
+ print(f"DevData restored from {rid}")
113
+ except RepositoryNotFoundError:
114
+ print(f"DevData dataset not found yet: {rid}")
115
+ except Exception as exc:
116
+ kind = classify_error(exc)
117
+ print(f"DevData restore warning [{kind}]: {exc}")
118
+ finally:
119
+ shutil.rmtree(tmp, ignore_errors=True)
120
+
121
+ def sync_loop(api: HfApi, rid: str):
122
+ while True:
123
+ tmp = Path(tempfile.mkdtemp(prefix="devdata-snap-"))
124
+ try:
125
+ snapshot(JUPYTER_ROOT, tmp)
126
+ upload_folder(folder_path=str(tmp), repo_id=rid, repo_type="dataset", token=HF_TOKEN,
127
+ commit_message=f"DevData sync {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
128
+ ignore_patterns=[".git/*", ".git"])
129
+ print(f"DevData synced to {rid}")
130
+ except Exception as exc:
131
+ kind = classify_error(exc)
132
+ print(f"DevData sync warning [{kind}]: {exc}")
133
+ finally:
134
+ shutil.rmtree(tmp, ignore_errors=True)
135
+ time.sleep(INTERVAL)
136
+
137
+ if __name__ == "__main__":
138
+ if not enabled():
139
+ print("DevData sync disabled.")
140
+ raise SystemExit(0)
141
+ from huggingface_hub import HfApi, upload_folder, snapshot_download
142
+ from huggingface_hub.errors import RepositoryNotFoundError
143
+
144
+ api = HfApi(token=HF_TOKEN)
145
+ rid = repo_id(api)
146
+ try:
147
+ api.repo_info(repo_id=rid, repo_type="dataset")
148
+ except RepositoryNotFoundError:
149
+ api.create_repo(repo_id=rid, repo_type="dataset", private=True)
150
+ validate_jupyter_paths()
151
+ restore_once(api, rid)
152
+ sync_loop(api, rid)
openclaw-sync.py CHANGED
@@ -483,7 +483,10 @@ def _sync_once_unlocked(
483
  commit_message=f"HuggingClaw sync {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
484
  ignore_patterns=[".git/*", ".git"],
485
  )
486
- prune_remote_deleted_files(repo_id, snapshot_dir)
 
 
 
487
  finally:
488
  shutil.rmtree(snapshot_dir, ignore_errors=True)
489
 
 
483
  commit_message=f"HuggingClaw sync {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
484
  ignore_patterns=[".git/*", ".git"],
485
  )
486
+ try:
487
+ prune_remote_deleted_files(repo_id, snapshot_dir)
488
+ except Exception as prune_exc:
489
+ print(f"Warning: could not prune stale remote files: {prune_exc}")
490
  finally:
491
  shutil.rmtree(snapshot_dir, ignore_errors=True)
492
 
start.sh CHANGED
@@ -8,7 +8,70 @@ umask 0077
8
  # ════════════════════════════════════════════════════════════════
9
 
10
  # ── Startup Banner ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}"
 
 
 
 
 
 
 
 
12
  OPENCLAW_APP_DIR="/home/node/.openclaw/openclaw-app"
13
  OPENCLAW_RUNTIME_VERSION=""
14
  OPENCLAW_FILE_LOG_LEVEL_CONFIGURED=false
@@ -24,11 +87,18 @@ WHATSAPP_ENABLED_NORMALIZED=$(printf '%s' "$WHATSAPP_ENABLED" | tr '[:upper:]' '
24
  DEV_MODE_RAW="${DEV_MODE:-false}"
25
  DEV_MODE_NORMALIZED=$(printf '%s' "$DEV_MODE_RAW" | tr '[:upper:]' '[:lower:]')
26
  DEV_MODE_ENABLED=false
27
- case "$DEV_MODE_NORMALIZED" in
28
- true|1|yes|on) DEV_MODE_ENABLED=true ;;
29
- *) DEV_MODE_ENABLED=false ;;
30
- esac
31
- SYNC_INTERVAL="${SYNC_INTERVAL:-180}"
 
 
 
 
 
 
 
32
  if [ -n "${SPACE_HOST:-}" ]; then
33
  OPENCLAW_CONSOLE_LOG_LEVEL="${OPENCLAW_CONSOLE_LOG_LEVEL:-warn}"
34
  OPENCLAW_FILE_LOG_LEVEL="${OPENCLAW_FILE_LOG_LEVEL:-info}"
@@ -207,6 +277,11 @@ export NPM_CONFIG_PREFIX="${NPM_CONFIG_PREFIX:-/home/node/.local}"
207
  export npm_config_prefix="$NPM_CONFIG_PREFIX"
208
  export PYTHONUSERBASE="${PYTHONUSERBASE:-/home/node/.local}"
209
  export DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"
 
 
 
 
 
210
  STARTUP_FILE="/home/node/.openclaw/workspace/startup.sh"
211
 
212
  # ── Restore workspace/state from HF Dataset ──
@@ -519,6 +594,24 @@ if [ -n "${ALLOWED_ORIGINS:-}" ]; then
519
  CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".gateway.controlUi.allowedOrigins += $ORIGINS_JSON | .gateway.controlUi.allowedOrigins |= unique")
520
  fi
521
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  # Telegram (supports multiple user IDs, comma-separated)
523
  if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
524
  CONFIG_JSON=$(echo "$CONFIG_JSON" | jq '.plugins.entries.telegram = {"enabled": true}')
@@ -531,12 +624,12 @@ if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
531
  # Force ipv4 for Telegram specifically as HF IPv6 often times out
532
  export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--dns-result-order=ipv4first"
533
 
534
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq --arg token "$CLEAN_TG_TOKEN" --arg proxy_url "${CLOUDFLARE_PROXY_URL:-}" '
535
  .channels.telegram.enabled = true
536
  | .channels.telegram.botToken = $token
537
  | .channels.telegram.commands.native = false
538
  | .channels.telegram.timeoutSeconds = 60
539
- | (if $proxy_url != "" then .channels.telegram.apiRoot = $proxy_url else .channels.telegram.apiRoot = "https://api.telegram.org" end)
540
  | .channels.telegram.retry = {
541
  "attempts": 5,
542
  "minDelayMs": 800,
@@ -600,7 +693,7 @@ if [ -f "$EXISTING_CONFIG" ]; then
600
  | .channels = ((.channels // {}) * ($desired.channels // {}))
601
  | .plugins.allow = (((.plugins.allow // []) + ($desired.plugins.allow // [])) | unique)
602
  | .plugins.deny = (((.plugins.deny // []) + ($desired.plugins.deny // [])) | unique)
603
- | .plugins.entries = (($desired.plugins.entries // {}) * (.plugins.entries // {}))
604
  | if $whatsappEnabled then
605
  ($desired.channels.whatsapp // {"dmPolicy": "pairing"}) as $desiredWhatsapp
606
  | .plugins.entries.whatsapp.enabled = true
@@ -736,12 +829,20 @@ export LLM_MODEL="$LLM_MODEL"
736
  node /home/node/app/health-server.js &
737
  HEALTH_PID=$!
738
 
739
- # 10.5. Start JupyterLab Terminal on internal port 8888 (DEV_MODE only)
740
- # Accessible via /terminal/ path through the health-server proxy
741
- if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ]; then
 
 
 
742
  JUPYTER_TOKEN="${JUPYTER_TOKEN:-huggingface}"
743
  JUPYTER_ROOT_DIR="${JUPYTER_ROOT_DIR:-/home/node}"
 
 
 
 
744
  mkdir -p "$JUPYTER_ROOT_DIR"
 
745
  if [ "$JUPYTER_ROOT_DIR" != "/home/node/app" ]; then
746
  if [ -L "$JUPYTER_ROOT_DIR/HuggingClaw" ] || [ ! -e "$JUPYTER_ROOT_DIR/HuggingClaw" ]; then
747
  ln -sfn /home/node/app "$JUPYTER_ROOT_DIR/HuggingClaw"
@@ -754,6 +855,7 @@ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ]; then
754
  fi
755
 
756
  echo "DEV_MODE enabled (${DEV_MODE_RAW}) — starting JupyterLab terminal on internal port 8888 (path: /terminal/) with root: $JUPYTER_ROOT_DIR"
 
757
  jupyter-lab \
758
  --ip 127.0.0.1 \
759
  --port 8888 \
@@ -761,6 +863,7 @@ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ]; then
761
  --IdentityProvider.token="$JUPYTER_TOKEN" \
762
  --ServerApp.base_url=/terminal/ \
763
  --ServerApp.terminals_enabled=True \
 
764
  --ServerApp.allow_origin='*' \
765
  --ServerApp.allow_remote_access=True \
766
  --ServerApp.trust_xheaders=True \
@@ -770,9 +873,15 @@ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ]; then
770
  --LabApp.news_url=None \
771
  --LabApp.check_for_updates_class="jupyterlab.NeverCheckForUpdate" \
772
  --notebook-dir="$JUPYTER_ROOT_DIR" \
773
- 2>&1 | tee -a /tmp/jupyterlab.log &
774
  JUPYTER_PID=$!
775
  echo "JupyterLab started (PID: $JUPYTER_PID)"
 
 
 
 
 
 
776
  else
777
  echo "Jupyter terminal disabled for this boot (DEV_MODE=${DEV_MODE_RAW})."
778
  fi
@@ -796,6 +905,9 @@ export NPM_CONFIG_PREFIX="${NPM_CONFIG_PREFIX:-/home/node/.local}"
796
  export npm_config_prefix="$NPM_CONFIG_PREFIX"
797
  export PYTHONUSERBASE="${PYTHONUSERBASE:-/home/node/.local}"
798
  export DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"
 
 
 
799
  STARTUP_FILE="/home/node/.openclaw/workspace/startup.sh"
800
  _hc_append() {
801
  if [ "${HUGGINGCLAW_CAPTURE_DISABLE:-0}" = "1" ]; then
@@ -1014,7 +1126,7 @@ openclaw() {
1014
  }
1015
  BASHRC
1016
  cat > /home/node/.profile <<'PROFILE'
1017
- [ -f ~/.bashrc ] && . ~/.bashrc
1018
  PROFILE
1019
  echo "Shell capture wrappers ready."
1020
 
@@ -1283,6 +1395,31 @@ sync_before_gateway_restart() {
1283
  echo "Warning: could not sync settled state before gateway restart"
1284
  }
1285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1286
  start_background_sync_once() {
1287
  [ -n "${HF_TOKEN:-}" ] || return 0
1288
 
@@ -1290,7 +1427,7 @@ start_background_sync_once() {
1290
  return 0
1291
  fi
1292
 
1293
- python3 -u /home/node/app/openclaw-sync.py loop &
1294
  SYNC_LOOP_PID=$!
1295
  }
1296
 
@@ -1307,6 +1444,11 @@ start_guardian_once() {
1307
  }
1308
 
1309
  while true; do
 
 
 
 
 
1310
  echo "Launching OpenClaw gateway on port 7860..."
1311
 
1312
  GATEWAY_ARGS=(gateway run --port 7860 --bind lan)
@@ -1356,6 +1498,7 @@ while true; do
1356
  # config edits can make OpenClaw exit/reload, and the gateway loop below will
1357
  # relaunch it without rerunning all startup code.
1358
  start_background_sync_once
 
1359
 
1360
  set +e
1361
  wait "$GATEWAY_PID"
 
8
  # ════════════════════════════════════════════════════════════════
9
 
10
  # ── Startup Banner ──
11
+ trim_var() {
12
+ # Trim leading/trailing whitespace from a value.
13
+ printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
14
+ }
15
+
16
+ hc_is_true() {
17
+ case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in
18
+ 1|true|yes|on) return 0 ;;
19
+ *) return 1 ;;
20
+ esac
21
+ }
22
+
23
+ load_env_bundle() {
24
+ # HUGGINGCLAW_ENV_BUNDLE is a single base64url-encoded JSON object generated
25
+ # by /env-builder. Existing individual env vars win over bundled values.
26
+ local bundle="${HUGGINGCLAW_ENV_BUNDLE:-${ENV_BUNDLE:-}}"
27
+ [ -n "$bundle" ] || return 0
28
+ eval "$(HUGGINGCLAW_ENV_BUNDLE="$bundle" python3 - <<'PYBUNDLE'
29
+ import base64, json, os, re, shlex, sys
30
+
31
+ raw = os.environ.get("HUGGINGCLAW_ENV_BUNDLE", "").strip()
32
+ try:
33
+ if raw.startswith("{"):
34
+ data = json.loads(raw)
35
+ else:
36
+ padded = raw + "=" * (-len(raw) % 4)
37
+ data = json.loads(base64.urlsafe_b64decode(padded.encode()).decode())
38
+ if not isinstance(data, dict):
39
+ raise ValueError("bundle must decode to a JSON object")
40
+ for key, value in data.items():
41
+ if not re.fullmatch(r"[A-Z_][A-Z0-9_]*", str(key)):
42
+ continue
43
+ if str(key) in {"HUGGINGCLAW_ENV_BUNDLE", "ENV_BUNDLE"}:
44
+ continue
45
+ if os.environ.get(str(key), ""):
46
+ continue
47
+ if value is None or isinstance(value, (dict, list)):
48
+ continue
49
+ print(f"export {key}={shlex.quote(str(value))}")
50
+ except Exception as exc:
51
+ print(f"Warning: invalid HUGGINGCLAW_ENV_BUNDLE ignored: {exc}", file=sys.stderr)
52
+ PYBUNDLE
53
+ )"
54
+ }
55
+
56
+ load_env_bundle
57
+
58
+ # Normalize core env values so accidental surrounding spaces in HF Variables
59
+ # do not block updates or cause stale comparisons/merges.
60
+ LLM_MODEL="$(trim_var "${LLM_MODEL:-}")"
61
+ GATEWAY_TOKEN="$(trim_var "${GATEWAY_TOKEN:-}")"
62
+ OPENCLAW_PASSWORD="$(trim_var "${OPENCLAW_PASSWORD:-}")"
63
+ LLM_API_KEY="$(trim_var "${LLM_API_KEY:-}")"
64
+ CLOUDFLARE_PROXY_URL="$(trim_var "${CLOUDFLARE_PROXY_URL:-}")"
65
+
66
  OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}"
67
+ APP_BASE="$(trim_var "${APP_BASE:-/app}")"
68
+ JUPYTER_BASE="$(trim_var "${JUPYTER_BASE:-/terminal}")"
69
+ PORT="$(trim_var "${PORT:-7861}")"
70
+ GATEWAY_PORT="$(trim_var "${GATEWAY_PORT:-7860}")"
71
+ JUPYTER_PORT="$(trim_var "${JUPYTER_PORT:-8888}")"
72
+ BACKUP_DATASET_NAME="$(trim_var "${BACKUP_DATASET_NAME:-${BACKUP_DATASET:-huggingclaw-backup}}")"
73
+ SPACE_AUTHOR_NAME="$(trim_var "${SPACE_AUTHOR_NAME:-}")"
74
+ SPACE_HOST="$(trim_var "${SPACE_HOST:-}")"
75
  OPENCLAW_APP_DIR="/home/node/.openclaw/openclaw-app"
76
  OPENCLAW_RUNTIME_VERSION=""
77
  OPENCLAW_FILE_LOG_LEVEL_CONFIGURED=false
 
87
  DEV_MODE_RAW="${DEV_MODE:-false}"
88
  DEV_MODE_NORMALIZED=$(printf '%s' "$DEV_MODE_RAW" | tr '[:upper:]' '[:lower:]')
89
  DEV_MODE_ENABLED=false
90
+ if hc_is_true "$DEV_MODE_NORMALIZED"; then
91
+ DEV_MODE_ENABLED=true
92
+ fi
93
+ SYNC_INTERVAL="$(trim_var "${SYNC_INTERVAL:-180}")"
94
+ DEVDATA_DATASET_NAME="$(trim_var "${DEVDATA_DATASET_NAME:-huggingclaw-devdata}")"
95
+ DEVDATA_SYNC_INTERVAL="$(trim_var "${DEVDATA_SYNC_INTERVAL:-180}")"
96
+ DEVDATA_RAW="$(trim_var "${DEVDATA:-on}")"
97
+ DEVDATA_NORMALIZED=$(printf '%s' "$DEVDATA_RAW" | tr '[:upper:]' '[:lower:]')
98
+ DEVDATA_ENABLED=true
99
+ if ! hc_is_true "$DEVDATA_NORMALIZED"; then
100
+ DEVDATA_ENABLED=false
101
+ fi
102
  if [ -n "${SPACE_HOST:-}" ]; then
103
  OPENCLAW_CONSOLE_LOG_LEVEL="${OPENCLAW_CONSOLE_LOG_LEVEL:-warn}"
104
  OPENCLAW_FILE_LOG_LEVEL="${OPENCLAW_FILE_LOG_LEVEL:-info}"
 
277
  export npm_config_prefix="$NPM_CONFIG_PREFIX"
278
  export PYTHONUSERBASE="${PYTHONUSERBASE:-/home/node/.local}"
279
  export DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"
280
+ # Show current working directory in terminal prompt (JupyterLab terminals can
281
+ # otherwise display only "$" when PS1 is unset/minimal).
282
+ if [ -z "${PS1:-}" ] || [ "$PS1" = "$ " ]; then
283
+ export PS1='\u@\h:\w\$ '
284
+ fi
285
  STARTUP_FILE="/home/node/.openclaw/workspace/startup.sh"
286
 
287
  # ── Restore workspace/state from HF Dataset ──
 
594
  CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".gateway.controlUi.allowedOrigins += $ORIGINS_JSON | .gateway.controlUi.allowedOrigins |= unique")
595
  fi
596
 
597
+ resolve_telegram_api_root() {
598
+ local candidate="$(trim_var "${CLOUDFLARE_PROXY_URL:-}")"
599
+ if [ -n "$candidate" ]; then
600
+ case "$candidate" in
601
+ http://*|https://*)
602
+ printf '%s' "$candidate"
603
+ return 0
604
+ ;;
605
+ *)
606
+ echo "Warning: invalid CLOUDFLARE_PROXY_URL '$candidate' (must start with http:// or https://); falling back to direct Telegram API." >&2
607
+ ;;
608
+ esac
609
+ fi
610
+ printf '%s' "https://api.telegram.org"
611
+ }
612
+ TELEGRAM_API_ROOT="$(resolve_telegram_api_root)"
613
+
614
+
615
  # Telegram (supports multiple user IDs, comma-separated)
616
  if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
617
  CONFIG_JSON=$(echo "$CONFIG_JSON" | jq '.plugins.entries.telegram = {"enabled": true}')
 
624
  # Force ipv4 for Telegram specifically as HF IPv6 often times out
625
  export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--dns-result-order=ipv4first"
626
 
627
+ CONFIG_JSON=$(echo "$CONFIG_JSON" | jq --arg token "$CLEAN_TG_TOKEN" --arg proxy_url "$TELEGRAM_API_ROOT" '
628
  .channels.telegram.enabled = true
629
  | .channels.telegram.botToken = $token
630
  | .channels.telegram.commands.native = false
631
  | .channels.telegram.timeoutSeconds = 60
632
+ | (if $proxy_url != "" then .channels.telegram.apiRoot = $proxy_url else . end)
633
  | .channels.telegram.retry = {
634
  "attempts": 5,
635
  "minDelayMs": 800,
 
693
  | .channels = ((.channels // {}) * ($desired.channels // {}))
694
  | .plugins.allow = (((.plugins.allow // []) + ($desired.plugins.allow // [])) | unique)
695
  | .plugins.deny = (((.plugins.deny // []) + ($desired.plugins.deny // [])) | unique)
696
+ | .plugins.entries = ((.plugins.entries // {}) * ($desired.plugins.entries // {}))
697
  | if $whatsappEnabled then
698
  ($desired.channels.whatsapp // {"dmPolicy": "pairing"}) as $desiredWhatsapp
699
  | .plugins.entries.whatsapp.enabled = true
 
829
  node /home/node/app/health-server.js &
830
  HEALTH_PID=$!
831
 
832
+ start_jupyter_once() {
833
+ [ "$RUNTIME_JUPYTER_ENABLED" = "true" ] || return 0
834
+ if [ -n "${JUPYTER_PID:-}" ] && kill -0 "$JUPYTER_PID" 2>/dev/null; then
835
+ return 0
836
+ fi
837
+
838
  JUPYTER_TOKEN="${JUPYTER_TOKEN:-huggingface}"
839
  JUPYTER_ROOT_DIR="${JUPYTER_ROOT_DIR:-/home/node}"
840
+ if [ "$JUPYTER_ROOT_DIR" = "/home/node/.openclaw/workspace" ] && [ "$DEVDATA_ENABLED" = "true" ]; then
841
+ echo "Jupyter root was set to OpenClaw workspace; moving Jupyter root to /home/node/devdata to keep BACKUP and DEVDATA datasets separate."
842
+ JUPYTER_ROOT_DIR="/home/node/devdata"
843
+ fi
844
  mkdir -p "$JUPYTER_ROOT_DIR"
845
+ export JUPYTER_ROOT_DIR
846
  if [ "$JUPYTER_ROOT_DIR" != "/home/node/app" ]; then
847
  if [ -L "$JUPYTER_ROOT_DIR/HuggingClaw" ] || [ ! -e "$JUPYTER_ROOT_DIR/HuggingClaw" ]; then
848
  ln -sfn /home/node/app "$JUPYTER_ROOT_DIR/HuggingClaw"
 
855
  fi
856
 
857
  echo "DEV_MODE enabled (${DEV_MODE_RAW}) — starting JupyterLab terminal on internal port 8888 (path: /terminal/) with root: $JUPYTER_ROOT_DIR"
858
+ JUPYTER_LOG_FILE="/tmp/jupyterlab.log"
859
  jupyter-lab \
860
  --ip 127.0.0.1 \
861
  --port 8888 \
 
863
  --IdentityProvider.token="$JUPYTER_TOKEN" \
864
  --ServerApp.base_url=/terminal/ \
865
  --ServerApp.terminals_enabled=True \
866
+ --ServerApp.terminado_settings='{"shell_command":["/bin/bash","-i"]}' \
867
  --ServerApp.allow_origin='*' \
868
  --ServerApp.allow_remote_access=True \
869
  --ServerApp.trust_xheaders=True \
 
873
  --LabApp.news_url=None \
874
  --LabApp.check_for_updates_class="jupyterlab.NeverCheckForUpdate" \
875
  --notebook-dir="$JUPYTER_ROOT_DIR" \
876
+ >> "$JUPYTER_LOG_FILE" 2>&1 &
877
  JUPYTER_PID=$!
878
  echo "JupyterLab started (PID: $JUPYTER_PID)"
879
+ }
880
+
881
+ # 10.5. Start JupyterLab Terminal on internal port 8888 (DEV_MODE only)
882
+ # Accessible via /terminal/ path through the health-server proxy
883
+ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ]; then
884
+ start_jupyter_once
885
  else
886
  echo "Jupyter terminal disabled for this boot (DEV_MODE=${DEV_MODE_RAW})."
887
  fi
 
905
  export npm_config_prefix="$NPM_CONFIG_PREFIX"
906
  export PYTHONUSERBASE="${PYTHONUSERBASE:-/home/node/.local}"
907
  export DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"
908
+ if [ -z "${PS1:-}" ] || [ "$PS1" = "$ " ]; then
909
+ export PS1="\u@\h:\w\$ "
910
+ fi
911
  STARTUP_FILE="/home/node/.openclaw/workspace/startup.sh"
912
  _hc_append() {
913
  if [ "${HUGGINGCLAW_CAPTURE_DISABLE:-0}" = "1" ]; then
 
1126
  }
1127
  BASHRC
1128
  cat > /home/node/.profile <<'PROFILE'
1129
+ [ -n "${BASH_VERSION:-}" ] && [ -f ~/.bashrc ] && . ~/.bashrc
1130
  PROFILE
1131
  echo "Shell capture wrappers ready."
1132
 
 
1395
  echo "Warning: could not sync settled state before gateway restart"
1396
  }
1397
 
1398
+ start_background_devdata_sync() {
1399
+ if [ "$DEV_MODE_ENABLED" != "true" ]; then
1400
+ return 0
1401
+ fi
1402
+ if [ "$DEVDATA_ENABLED" != "true" ]; then
1403
+ echo "DevData : disabled by DEVDATA=${DEVDATA_RAW}"
1404
+ return 0
1405
+ fi
1406
+ if [ -z "${HF_TOKEN:-}" ]; then
1407
+ echo "DevData : disabled (HF_TOKEN missing)"
1408
+ return 0
1409
+ fi
1410
+ if [ "${DEVDATA_DATASET_NAME:-huggingclaw-devdata}" = "${BACKUP_DATASET_NAME:-huggingclaw-backup}" ]; then
1411
+ echo "DevData : disabled (DEVDATA_DATASET_NAME must be separate from BACKUP_DATASET_NAME)"
1412
+ return 0
1413
+ fi
1414
+ if [ ! -f "/home/node/app/jupyter-devdata-sync.py" ]; then
1415
+ echo "DevData : script missing; skipped"
1416
+ return 0
1417
+ fi
1418
+ echo "DevData : enabled (dataset=${DEVDATA_DATASET_NAME:-huggingclaw-devdata})"
1419
+ python3 -u /home/node/app/jupyter-devdata-sync.py >> /tmp/devdata-sync.log 2>&1 &
1420
+ DEVDATA_SYNC_PID=$!
1421
+ }
1422
+
1423
  start_background_sync_once() {
1424
  [ -n "${HF_TOKEN:-}" ] || return 0
1425
 
 
1427
  return 0
1428
  fi
1429
 
1430
+ python3 -u /home/node/app/openclaw-sync.py loop >> /tmp/workspace-sync.log 2>&1 &
1431
  SYNC_LOOP_PID=$!
1432
  }
1433
 
 
1444
  }
1445
 
1446
  while true; do
1447
+ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ] && [ -n "${JUPYTER_PID:-}" ] && ! kill -0 "$JUPYTER_PID" 2>/dev/null; then
1448
+ echo "Warning: JupyterLab exited; attempting restart."
1449
+ start_jupyter_once
1450
+ fi
1451
+
1452
  echo "Launching OpenClaw gateway on port 7860..."
1453
 
1454
  GATEWAY_ARGS=(gateway run --port 7860 --bind lan)
 
1498
  # config edits can make OpenClaw exit/reload, and the gateway loop below will
1499
  # relaunch it without rerunning all startup code.
1500
  start_background_sync_once
1501
+ start_background_devdata_sync
1502
 
1503
  set +e
1504
  wait "$GATEWAY_PID"