Anurag commited on
Commit
68fc4b1
Β·
unverified Β·
2 Parent(s): c8c3bcbb05a526

Merge branch 'main' into main

Browse files
Files changed (5) hide show
  1. Dockerfile +3 -1
  2. env-builder.js +5 -5
  3. health-server.js +93 -6
  4. login.html +12 -22
  5. start.sh +11 -17
Dockerfile CHANGED
@@ -15,7 +15,9 @@ FROM ghcr.io/openclaw/openclaw:${OPENCLAW_VERSION} AS openclaw
15
  FROM node:22-slim
16
  ARG OPENCLAW_VERSION=latest
17
  ARG DEV_MODE=false
18
- ENV DEV_MODE=${DEV_MODE}
 
 
19
 
20
  # Install system dependencies (+ optional JupyterLab deps in DEV_MODE)
21
  RUN apt-get update && apt-get install -y \
 
15
  FROM node:22-slim
16
  ARG OPENCLAW_VERSION=latest
17
  ARG DEV_MODE=false
18
+ # DEV_MODE intentionally not baked into runtime ENV β€” defaults to unset so
19
+ # start.sh can auto-enable terminal when GATEWAY_TOKEN is present. Users can
20
+ # override by setting DEV_MODE=false as an HF Space Variable to opt out.
21
 
22
  # Install system dependencies (+ optional JupyterLab deps in DEV_MODE)
23
  RUN apt-get update && apt-get install -y \
env-builder.js CHANGED
@@ -853,11 +853,11 @@ const FIELDS = [
853
  "k": "JUPYTER_TOKEN",
854
  "lbl": "Jupyter access token (Must NOT be 'huggingface'. Run: openssl rand -hex 32)",
855
  "type": "password",
856
- "ph": "change_this_to_a_strong_token",
857
- "common": 1,
858
- "tag": "credential"
859
  },
860
- {
861
  "g": "Core",
862
  "icon": "⚑",
863
  "k": "OPENCLAW_DISABLE_BONJOUR",
@@ -965,7 +965,7 @@ const FIELDS = [
965
  "ph": "/home/node",
966
  "tag": "advanced"
967
  },
968
- {
969
  "g": "Provider Keys",
970
  "icon": "πŸ”‘",
971
  "k": "ANTHROPIC_API_KEY",
 
853
  "k": "JUPYTER_TOKEN",
854
  "lbl": "Jupyter access token (Must NOT be 'huggingface'. Run: openssl rand -hex 32)",
855
  "type": "password",
856
+ "secret": 1,
857
+ "ph": "huggingface",
858
+ "common": 1
859
  },
860
+ {
861
  "g": "Core",
862
  "icon": "⚑",
863
  "k": "OPENCLAW_DISABLE_BONJOUR",
 
965
  "ph": "/home/node",
966
  "tag": "advanced"
967
  },
968
+ {
969
  "g": "Provider Keys",
970
  "icon": "πŸ”‘",
971
  "k": "ANTHROPIC_API_KEY",
health-server.js CHANGED
@@ -22,11 +22,10 @@ const JUPYTER_HOST = "127.0.0.1";
22
  const JUPYTER_BASE = normalizeBase(process.env.JUPYTER_BASE, "/terminal");
23
  const GATEWAY_TOKEN = (process.env.GATEWAY_TOKEN || "").trim();
24
  const DEV_MODE_ENABLED = isTrue(process.env.DEV_MODE);
25
- // Auto-enable Jupyter when DEV_MODE=true, HUGGINGCLAW_JUPYTER_ENABLED=true, or GATEWAY_TOKEN is set.
26
- // GATEWAY_TOKEN doubles as JUPYTER_TOKEN in start.sh β€” no extra secret needed.
27
- const JUPYTER_ENABLED = /^(true|1|yes|on)$/i.test(
28
- process.env.HUGGINGCLAW_JUPYTER_ENABLED || (DEV_MODE_ENABLED ? "true" : GATEWAY_TOKEN ? "true" : "false")
29
- );
30
  const startTime = Date.now();
31
  const LLM_MODEL = process.env.LLM_MODEL || "Not Set";
32
  const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN;
@@ -240,6 +239,65 @@ function escapeHtml(v) {
240
  return String(v).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
241
  }
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  function badge(label, tone = "neutral") {
244
  return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
245
  }
@@ -599,16 +657,45 @@ const server = http.createServer(async (req, res) => {
599
  !isSameOriginNav &&
600
  !isFromHFApp;
601
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
602
  if (pathname === "/env-builder" || pathname === "/env-builder/") {
603
  if (isDirectHfSpaceRequest) {
604
  res.writeHead(200, { "Content-Type": "text/html" });
605
  return res.end(renderPrivateRedirect(HF_SPACE_URL));
606
  }
 
 
 
 
607
  res.writeHead(200, { "Content-Type": "text/html" });
608
  return res.end(renderEnvBuilder());
609
  }
610
 
611
  if (pathname === "/env-builder.js") {
 
 
 
 
612
  try {
613
  const js = fs.readFileSync(require("path").join(__dirname, "env-builder.js"), "utf8");
614
  res.writeHead(200, { "Content-Type": "application/javascript" });
@@ -637,7 +724,7 @@ const server = http.createServer(async (req, res) => {
637
  if (pathname === JUPYTER_BASE || pathname.startsWith(JUPYTER_BASE + "/")) {
638
  if (!JUPYTER_ENABLED) {
639
  res.writeHead(404, { "Content-Type": "application/json" });
640
- return res.end(JSON.stringify({ status: "disabled", message: "JupyterLab terminal is disabled. Set GATEWAY_TOKEN or DEV_MODE=true to enable /terminal/ (or set HUGGINGCLAW_JUPYTER_ENABLED=true)." }));
641
  }
642
  if (isDirectHfSpaceRequest) {
643
  res.writeHead(200, { "Content-Type": "text/html" });
 
22
  const JUPYTER_BASE = normalizeBase(process.env.JUPYTER_BASE, "/terminal");
23
  const GATEWAY_TOKEN = (process.env.GATEWAY_TOKEN || "").trim();
24
  const DEV_MODE_ENABLED = isTrue(process.env.DEV_MODE);
25
+ // Default true. Only false when DEV_MODE=false or HUGGINGCLAW_JUPYTER_ENABLED=false is explicitly set.
26
+ const JUPYTER_ENABLED =
27
+ !/^(false|0|no|off)$/i.test(String(process.env.DEV_MODE || "").trim()) &&
28
+ !/^(false|0|no|off)$/i.test(String(process.env.HUGGINGCLAW_JUPYTER_ENABLED || "").trim());
 
29
  const startTime = Date.now();
30
  const LLM_MODEL = process.env.LLM_MODEL || "Not Set";
31
  const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN;
 
239
  return String(v).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
240
  }
241
 
242
+ function parseCookies(req) {
243
+ const h = req.headers.cookie || "";
244
+ return Object.fromEntries(h.split(";").map(c => c.trim().split("=")).filter(p => p.length >= 2).map(([k, ...v]) => [k.trim(), decodeURIComponent(v.join("=").trim())]));
245
+ }
246
+
247
+ // Constant-time comparison β€” prevent timing attacks on token check
248
+ function safeEqual(a, b) {
249
+ if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) return false;
250
+ let d = 0;
251
+ for (let i = 0; i < a.length; i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
252
+ return d === 0;
253
+ }
254
+
255
+ function isEnvBuilderAuthed(req) {
256
+ if (!GATEWAY_TOKEN) return true; // unprotected when no token set
257
+ return safeEqual(parseCookies(req).hc_env_auth || "", GATEWAY_TOKEN);
258
+ }
259
+
260
+ function readBody(req) {
261
+ return new Promise((resolve) => {
262
+ let body = "";
263
+ req.on("data", chunk => { body += chunk; if (body.length > 4096) { body = ""; req.destroy(); } });
264
+ req.on("end", () => resolve(body));
265
+ req.on("error", () => resolve(""));
266
+ });
267
+ }
268
+
269
+ function renderEnvBuilderLogin(error = false) {
270
+ return `<!doctype html><html lang="en"><head>
271
+ <meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
272
+ <title>HuggingClaw β€” Env Builder</title>
273
+ <style>
274
+ :root{color-scheme:dark;--bg:#08080f;--panel:#12111b;--line:#26243a;--text:#f6f4ff;--muted:#7f7a9e;--bad:#fb7185}
275
+ *{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);padding:24px}
276
+ .card{border:1px solid var(--line);background:var(--panel);border-radius:14px;padding:36px 32px;max-width:400px;width:100%;text-align:center}
277
+ h1{margin:0 0 8px;font-size:1.4rem}
278
+ .sub{color:var(--muted);font-size:.82rem;margin:0 0 24px}
279
+ .row{display:flex;gap:8px;margin-top:16px}
280
+ input{flex:1;background:#0d0c18;border:1px solid var(--line);border-radius:7px;padding:10px 12px;color:var(--text);font-size:.95rem;outline:none}
281
+ input:focus{border-color:#6366f1}
282
+ button{background:#fff;color:#000;border:none;border-radius:7px;padding:10px 20px;font-weight:700;font-size:.95rem;cursor:pointer;transition:opacity .15s}
283
+ button:hover{opacity:.85}
284
+ .err{color:var(--bad);font-size:.82rem;margin-top:10px}
285
+ code{background:#232234;border:1px solid #34324c;border-radius:5px;padding:2px 6px;font-size:.88em}
286
+ </style></head><body>
287
+ <div class="card">
288
+ <h1>βš™οΈ Env Builder</h1>
289
+ <p class="sub">Enter your <code>GATEWAY_TOKEN</code> to continue</p>
290
+ <form method="post" action="/env-builder/login">
291
+ <div class="row">
292
+ <input type="password" name="token" placeholder="GATEWAY_TOKEN" autofocus autocomplete="current-password">
293
+ <button type="submit">Unlock</button>
294
+ </div>
295
+ ${error ? '<p class="err">Invalid token β€” try again</p>' : ""}
296
+ </form>
297
+ </div>
298
+ </body></html>`;
299
+ }
300
+
301
  function badge(label, tone = "neutral") {
302
  return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
303
  }
 
657
  !isSameOriginNav &&
658
  !isFromHFApp;
659
 
660
+ if (pathname === "/env-builder/login") {
661
+ if (req.method === "POST") {
662
+ const body = await readBody(req);
663
+ const token = decodeURIComponent((body.match(/(?:^|&)token=([^&]*)/) || [])[1] || "").replace(/\+/g, " ");
664
+ if (safeEqual(token, GATEWAY_TOKEN)) {
665
+ const cookie = `hc_env_auth=${encodeURIComponent(GATEWAY_TOKEN)}; Path=/env-builder; HttpOnly; SameSite=Strict; Max-Age=86400`;
666
+ res.writeHead(302, { Location: "/env-builder", "Set-Cookie": cookie, "Cache-Control": "no-store" });
667
+ return res.end();
668
+ }
669
+ res.writeHead(200, { "Content-Type": "text/html" });
670
+ return res.end(renderEnvBuilderLogin(true));
671
+ }
672
+ res.writeHead(302, { Location: "/env-builder", "Cache-Control": "no-store" });
673
+ return res.end();
674
+ }
675
+
676
+ if (pathname === "/env-builder/logout") {
677
+ res.writeHead(302, { Location: "/env-builder", "Set-Cookie": "hc_env_auth=; Path=/env-builder; HttpOnly; Max-Age=0", "Cache-Control": "no-store" });
678
+ return res.end();
679
+ }
680
+
681
  if (pathname === "/env-builder" || pathname === "/env-builder/") {
682
  if (isDirectHfSpaceRequest) {
683
  res.writeHead(200, { "Content-Type": "text/html" });
684
  return res.end(renderPrivateRedirect(HF_SPACE_URL));
685
  }
686
+ if (!isEnvBuilderAuthed(req)) {
687
+ res.writeHead(200, { "Content-Type": "text/html" });
688
+ return res.end(renderEnvBuilderLogin(false));
689
+ }
690
  res.writeHead(200, { "Content-Type": "text/html" });
691
  return res.end(renderEnvBuilder());
692
  }
693
 
694
  if (pathname === "/env-builder.js") {
695
+ if (!isEnvBuilderAuthed(req)) {
696
+ res.writeHead(401, { "Content-Type": "text/plain" });
697
+ return res.end("Unauthorized");
698
+ }
699
  try {
700
  const js = fs.readFileSync(require("path").join(__dirname, "env-builder.js"), "utf8");
701
  res.writeHead(200, { "Content-Type": "application/javascript" });
 
724
  if (pathname === JUPYTER_BASE || pathname.startsWith(JUPYTER_BASE + "/")) {
725
  if (!JUPYTER_ENABLED) {
726
  res.writeHead(404, { "Content-Type": "application/json" });
727
+ return res.end(JSON.stringify({ status: "disabled", message: "JupyterLab terminal is disabled. Remove DEV_MODE=false to re-enable." }));
728
  }
729
  if (isDirectHfSpaceRequest) {
730
  res.writeHead(200, { "Content-Type": "text/html" });
login.html CHANGED
@@ -8,36 +8,26 @@
8
  <img src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg" alt="Hugging Face Logo" style="max-width: 120px; margin-bottom: 24px;">
9
  <h3>HuggingClaw Terminal</h3>
10
  <h4>Welcome to JupyterLab</h4>
11
- <p style="color:#666;">Enter the <strong>JUPYTER_TOKEN</strong> you set in your Space secrets to access the terminal.</p>
12
- <p style="color:#666;">This terminal is mounted at <code>/terminal/</code> inside the same Hugging Face Space as the OpenClaw UI.</p>
13
 
14
  {% if login_available %}
15
- <div class="row" style="display:flex; justify-content:center; margin-top:24px;">
16
- <div class="navbar col-sm-8">
17
- <div class="navbar-inner">
18
- <div class="container">
19
- <div class="center-nav">
20
- <form action="{{base_url}}login?next={{next}}" method="post" class="navbar-form pull-left">
21
- {{ xsrf_form_html() | safe }}
22
- {% if token_available %}
23
- <label for="password_input"><strong>{% trans %}Jupyter token <span title="This is the secret you set up when deploying your JupyterLab terminal">β“˜</span> {% endtrans %}</strong></label>
24
- {% else %}
25
- <label for="password_input"><strong>{% trans %}Jupyter password:{% endtrans %}</strong></label>
26
- {% endif %}
27
- <input type="password" name="password" id="password_input" class="form-control">
28
- <button type="submit" class="btn btn-default" id="login_submit">{% trans %}Log in{% endtrans %}</button>
29
- </form>
30
- </div>
31
- </div>
32
- </div>
33
- </div>
34
  </div>
35
  {% else %}
36
  <p>{% trans %}No login available, you shouldn't be seeing this page.{% endtrans %}</p>
37
  {% endif %}
38
 
39
  <h5 style="margin-top:28px;"><a href="/dashboard">Back to HuggingClaw dashboard</a></h5>
40
- <p>This login page is based on the Hugging Face JupyterLab Space template.</p>
41
 
42
  {% if message %}
43
  <div class="row">
 
8
  <img src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg" alt="Hugging Face Logo" style="max-width: 120px; margin-bottom: 24px;">
9
  <h3>HuggingClaw Terminal</h3>
10
  <h4>Welcome to JupyterLab</h4>
11
+ <p style="color:#666;">Token defaults to your <code>GATEWAY_TOKEN</code>. Set <code>JUPYTER_TOKEN</code> to override.</p>
 
12
 
13
  {% if login_available %}
14
+ <div style="display:flex; justify-content:center; margin-top:24px;">
15
+ <form action="{{base_url}}login?next={{next}}" method="post" style="display:flex; align-items:center; gap:8px;">
16
+ {{ xsrf_form_html() | safe }}
17
+ {% if token_available %}
18
+ <label for="password_input"><strong>{% trans %}Jupyter token <span title="Your GATEWAY_TOKEN (or JUPYTER_TOKEN if set)">β“˜</span>{% endtrans %}</strong></label>
19
+ {% else %}
20
+ <label for="password_input"><strong>{% trans %}Jupyter password:{% endtrans %}</strong></label>
21
+ {% endif %}
22
+ <input type="password" name="password" id="password_input" class="form-control">
23
+ <button type="submit" class="btn btn-default" id="login_submit">{% trans %}Log in{% endtrans %}</button>
24
+ </form>
 
 
 
 
 
 
 
 
25
  </div>
26
  {% else %}
27
  <p>{% trans %}No login available, you shouldn't be seeing this page.{% endtrans %}</p>
28
  {% endif %}
29
 
30
  <h5 style="margin-top:28px;"><a href="/dashboard">Back to HuggingClaw dashboard</a></h5>
 
31
 
32
  {% if message %}
33
  <div class="row">
start.sh CHANGED
@@ -97,7 +97,7 @@ fi
97
  # GATEWAY_TOKEN doubles as JUPYTER_TOKEN (see start_jupyter_once) β€” no extra secret required.
98
  if [ "$DEV_MODE_ENABLED" != "true" ] && [ -z "${DEV_MODE:-}" ] && [ -n "${GATEWAY_TOKEN:-}" ]; then
99
  DEV_MODE_ENABLED=true
100
- echo "GATEWAY_TOKEN set and DEV_MODE not explicitly configured β€” auto-enabling terminal (set DEV_MODE=false to opt out)"
101
  fi
102
  SYNC_INTERVAL="$(trim_var "${SYNC_INTERVAL:-180}")"
103
  DEVDATA_DATASET_NAME="$(trim_var "${DEVDATA_DATASET_NAME:-huggingclaw-devdata}")"
@@ -875,12 +875,12 @@ export PATH="$HOME/.local/bin:$PATH"
875
 
876
  # Runtime install fallback: only attempt if DEV_MODE is enabled but install failed during build
877
  if [ "$DEV_MODE_ENABLED" = "true" ] && ! python3 -c "import jupyterlab" >/dev/null 2>&1; then
878
- echo "DEV_MODE enabled but jupyter-lab is missing; attempting runtime install..."
879
- if python3 -m pip install --user --no-cache-dir --break-system-packages "jupyterlab>=4.2,<5" "tornado>=6.3" "ipywidgets>=8.1"; then
880
- echo "Runtime Jupyter install complete."
881
  python3 -c "from pathlib import Path; import shutil, jupyter_server; d=Path(jupyter_server.__file__).parent/'templates'; d.mkdir(parents=True,exist_ok=True); shutil.copyfile('/home/node/app/login.html', d/'login.html')" || true
882
  else
883
- echo "WARNING: Runtime Jupyter install failed; disabling terminal for this boot."
884
  RUNTIME_JUPYTER_ENABLED=false
885
  fi
886
  fi
@@ -896,7 +896,6 @@ if [ -n "${SPACE_HOST:-}" ]; then
896
  else
897
  echo "Routes : /app/ (Control UI)"
898
  fi
899
- echo "Private : open the Hugging Face App tab first; raw https://${SPACE_HOST}/... links can show HF 404 without the embedded Space session."
900
  fi
901
  echo ""
902
 
@@ -984,7 +983,6 @@ start_jupyter_once() {
984
  # reuse GATEWAY_TOKEN. Both protect the same Space, so the credential is equivalent.
985
  if { [ -z "${JUPYTER_TOKEN:-}" ] || [ "${JUPYTER_TOKEN}" = "huggingface" ]; } && [ -n "${GATEWAY_TOKEN:-}" ]; then
986
  JUPYTER_TOKEN="$GATEWAY_TOKEN"
987
- echo "JUPYTER_TOKEN not set β€” using GATEWAY_TOKEN as terminal auth token"
988
  fi
989
 
990
  # Security guard: refuse to start JupyterLab with the insecure default token.
@@ -1017,7 +1015,7 @@ start_jupyter_once() {
1017
  # Pre-create runtime directory
1018
  mkdir -p "$JUPYTER_ROOT_DIR/.jupyter"
1019
 
1020
- echo "DEV_MODE enabled (${DEV_MODE_RAW}) β€” starting JupyterLab terminal on internal port 8888 (path: /terminal/) with root: $JUPYTER_ROOT_DIR"
1021
  JUPYTER_LOG_FILE="/tmp/jupyterlab.log"
1022
 
1023
  # Use explicit Python to avoid PATH issues; set memory-friendly limits
@@ -1043,7 +1041,7 @@ start_jupyter_once() {
1043
  >> "$JUPYTER_LOG_FILE" 2>&1 &
1044
  JUPYTER_PID=$!
1045
  export JUPYTER_PID
1046
- echo "JupyterLab started (PID: $JUPYTER_PID)"
1047
  }
1048
 
1049
  # BUG FIX #3: DevData restore must happen BEFORE JupyterLab starts.
@@ -1056,9 +1054,9 @@ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ] && \
1056
  [ -n "${HF_TOKEN:-}" ] && \
1057
  [ -f "/home/node/app/jupyter-devdata-sync.py" ] && \
1058
  [ "${DEVDATA_DATASET_NAME:-huggingclaw-devdata}" != "${BACKUP_DATASET_NAME:-huggingclaw-backup}" ]; then
1059
- echo "DevData : restoring workspace from ${DEVDATA_DATASET_NAME:-huggingclaw-devdata} (before JupyterLab starts)..."
1060
- python3 /home/node/app/jupyter-devdata-sync.py --restore || \
1061
- echo "DevData : restore warning (non-fatal); continuing startup."
1062
  fi
1063
 
1064
  # Fix: reinstall jsonschema AFTER devdata restore β€” restore can overwrite a broken
@@ -1066,9 +1064,7 @@ fi
1066
  # JupyterLab to crash with a circular import error on every boot.
1067
  if [ "$DEV_MODE_ENABLED" = "true" ]; then
1068
  if ! python3 -c "import jsonschema" >/dev/null 2>&1; then
1069
- echo "DevData : jsonschema broken after restore β€” reinstalling (circular import fix)..."
1070
- python3 -m pip install --force-reinstall --no-cache-dir --break-system-packages "jsonschema>=4.0" >/dev/null 2>&1 || true
1071
- echo "DevData : jsonschema reinstall done."
1072
  fi
1073
  fi
1074
 
@@ -1076,8 +1072,6 @@ fi
1076
  # Accessible via /terminal/ path through the health-server proxy
1077
  if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ]; then
1078
  start_jupyter_once
1079
- else
1080
- echo "Jupyter terminal disabled for this boot (DEV_MODE=${DEV_MODE_RAW})."
1081
  fi
1082
 
1083
  if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then
 
97
  # GATEWAY_TOKEN doubles as JUPYTER_TOKEN (see start_jupyter_once) β€” no extra secret required.
98
  if [ "$DEV_MODE_ENABLED" != "true" ] && [ -z "${DEV_MODE:-}" ] && [ -n "${GATEWAY_TOKEN:-}" ]; then
99
  DEV_MODE_ENABLED=true
100
+ : # auto-enable is silent; set DEV_MODE=false to opt out
101
  fi
102
  SYNC_INTERVAL="$(trim_var "${SYNC_INTERVAL:-180}")"
103
  DEVDATA_DATASET_NAME="$(trim_var "${DEVDATA_DATASET_NAME:-huggingclaw-devdata}")"
 
875
 
876
  # Runtime install fallback: only attempt if DEV_MODE is enabled but install failed during build
877
  if [ "$DEV_MODE_ENABLED" = "true" ] && ! python3 -c "import jupyterlab" >/dev/null 2>&1; then
878
+ echo "Terminal : installing JupyterLab..."
879
+ if python3 -m pip install -q --user --no-cache-dir --break-system-packages "jupyterlab>=4.2,<5" "tornado>=6.3" "ipywidgets>=8.1" >/dev/null 2>&1; then
880
+ echo "Terminal : installed"
881
  python3 -c "from pathlib import Path; import shutil, jupyter_server; d=Path(jupyter_server.__file__).parent/'templates'; d.mkdir(parents=True,exist_ok=True); shutil.copyfile('/home/node/app/login.html', d/'login.html')" || true
882
  else
883
+ echo "Terminal : install failed β€” disabling for this boot"
884
  RUNTIME_JUPYTER_ENABLED=false
885
  fi
886
  fi
 
896
  else
897
  echo "Routes : /app/ (Control UI)"
898
  fi
 
899
  fi
900
  echo ""
901
 
 
983
  # reuse GATEWAY_TOKEN. Both protect the same Space, so the credential is equivalent.
984
  if { [ -z "${JUPYTER_TOKEN:-}" ] || [ "${JUPYTER_TOKEN}" = "huggingface" ]; } && [ -n "${GATEWAY_TOKEN:-}" ]; then
985
  JUPYTER_TOKEN="$GATEWAY_TOKEN"
 
986
  fi
987
 
988
  # Security guard: refuse to start JupyterLab with the insecure default token.
 
1015
  # Pre-create runtime directory
1016
  mkdir -p "$JUPYTER_ROOT_DIR/.jupyter"
1017
 
1018
+ echo "Terminal : starting (root: $JUPYTER_ROOT_DIR)"
1019
  JUPYTER_LOG_FILE="/tmp/jupyterlab.log"
1020
 
1021
  # Use explicit Python to avoid PATH issues; set memory-friendly limits
 
1041
  >> "$JUPYTER_LOG_FILE" 2>&1 &
1042
  JUPYTER_PID=$!
1043
  export JUPYTER_PID
1044
+ echo "Terminal : started (PID: $JUPYTER_PID)"
1045
  }
1046
 
1047
  # BUG FIX #3: DevData restore must happen BEFORE JupyterLab starts.
 
1054
  [ -n "${HF_TOKEN:-}" ] && \
1055
  [ -f "/home/node/app/jupyter-devdata-sync.py" ] && \
1056
  [ "${DEVDATA_DATASET_NAME:-huggingclaw-devdata}" != "${BACKUP_DATASET_NAME:-huggingclaw-backup}" ]; then
1057
+ echo "DevData : restoring workspace..."
1058
+ python3 /home/node/app/jupyter-devdata-sync.py --restore 2>/dev/null || \
1059
+ echo "DevData : restore warning (non-fatal); continuing startup."
1060
  fi
1061
 
1062
  # Fix: reinstall jsonschema AFTER devdata restore β€” restore can overwrite a broken
 
1064
  # JupyterLab to crash with a circular import error on every boot.
1065
  if [ "$DEV_MODE_ENABLED" = "true" ]; then
1066
  if ! python3 -c "import jsonschema" >/dev/null 2>&1; then
1067
+ python3 -m pip install -q --force-reinstall --no-cache-dir --break-system-packages "jsonschema>=4.0" >/dev/null 2>&1 || true
 
 
1068
  fi
1069
  fi
1070
 
 
1072
  # Accessible via /terminal/ path through the health-server proxy
1073
  if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ]; then
1074
  start_jupyter_once
 
 
1075
  fi
1076
 
1077
  if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then