Somrat Sorkar commited on
Commit
7807f9d
·
unverified ·
2 Parent(s): dd751b87baec40

Merge pull request #17 from anurag162008/main

Browse files

## feat: Merge JupyterLab terminal into HuggingClaw (DEV_MODE gated)

.env.example CHANGED
@@ -247,6 +247,10 @@ LLM_API_KEY_FALLBACK_ENABLED=true
247
  # Generate: openssl rand -hex 32
248
  GATEWAY_TOKEN=your_gateway_token_here
249
 
 
 
 
 
250
  # (Optional) Password auth — simpler alternative to token for casual users
251
  # If set, users can log in with this password instead of the token
252
  # OPENCLAW_PASSWORD=your_password_here
 
247
  # Generate: openssl rand -hex 32
248
  GATEWAY_TOKEN=your_gateway_token_here
249
 
250
+ # [OPTIONAL] JupyterLab terminal token for /terminal/
251
+ # Defaults to "huggingface" if unset. Set a strong token for private deployments.
252
+ JUPYTER_TOKEN=huggingface
253
+
254
  # (Optional) Password auth — simpler alternative to token for casual users
255
  # If set, users can log in with this password instead of the token
256
  # OPENCLAW_PASSWORD=your_password_here
.gitattributes ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tflite filter=lfs diff=lfs merge=lfs -text
29
+ *.tgz filter=lfs diff=lfs merge=lfs -text
30
+ *.wasm filter=lfs diff=lfs merge=lfs -text
31
+ *.xz filter=lfs diff=lfs merge=lfs -text
32
+ *.zip filter=lfs diff=lfs merge=lfs -text
33
+ *.zst filter=lfs diff=lfs merge=lfs -text
34
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -1,21 +1,20 @@
1
- # personal workflows
2
- .github/workflows/
3
- .git*
4
  # env/secrets
5
  .env
6
  .env.*
 
7
  *.pem
8
  *.key
9
 
10
  # node
11
  node_modules/
 
12
  npm-debug.log*
13
  yarn-debug.log*
14
  pnpm-debug.log*
15
 
16
  # python
17
  __pycache__/
18
- *.pyc
19
  .venv/
20
  venv/
21
 
@@ -33,12 +32,10 @@ build/
33
  .cache/
34
 
35
  # temp
 
36
  tmp/
37
  temp/
38
 
39
  # os junk
40
  .DS_Store
41
  Thumbs.db
42
-
43
- # gitignore itself
44
- .gitignore
 
 
 
 
1
  # env/secrets
2
  .env
3
  .env.*
4
+ !.env.example
5
  *.pem
6
  *.key
7
 
8
  # node
9
  node_modules/
10
+ gui/node_modules/
11
  npm-debug.log*
12
  yarn-debug.log*
13
  pnpm-debug.log*
14
 
15
  # python
16
  __pycache__/
17
+ *.py[cod]
18
  .venv/
19
  venv/
20
 
 
32
  .cache/
33
 
34
  # temp
35
+ .tmp/
36
  tmp/
37
  temp/
38
 
39
  # os junk
40
  .DS_Store
41
  Thumbs.db
 
 
 
CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
 
3
  All notable changes to this project will be documented in this file.
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  ## [1.5.0] - 2026-05-13
6
 
7
  ### Added
 
2
 
3
  All notable changes to this project will be documented in this file.
4
 
5
+ ## [1.6.0] - 2026-05-14
6
+
7
+ ### Added
8
+
9
+ - **Merged JupyterLab terminal** — added the Hugging Face JupyterLab template login page and `/terminal/` routing alongside the HuggingClaw dashboard and `/app/` OpenClaw Control UI.
10
+ - **HF template parity files** — restored Git LFS defaults and project metadata/docs that were missing from the merged repository.
11
+
12
+ ### Fixed
13
+
14
+ - **HF Spaces subpath routing** — normalized internal redirects and WebSocket forwarding so `/app/` and `/terminal/` stay mounted behind the single public Spaces port.
15
+ - **Startup log noise** — removed the stale disabled `plugins.entries.acpx` config entry, switched Jupyter token/cookie options to `IdentityProvider.*`, and printed `/app/` with the trailing slash.
16
+ - **Private Space navigation** — dashboard buttons now stay in-frame and startup logs print relative routes, avoiding raw `.hf.space` links that can show Hugging Face's outer 404 page on private Spaces.
17
+
18
  ## [1.5.0] - 2026-05-13
19
 
20
  ### Added
CONTRIBUTING.md CHANGED
@@ -17,7 +17,7 @@ Thanks for your interest in contributing! 🦞
17
  1. Fork the repo
18
  2. Create a feature branch: `git checkout -b feature/my-feature`
19
  3. Make your changes
20
- 4. Test locally with Docker: `docker build -t huggingclaw . && docker run -p 7860:7860 --env-file .env huggingclaw`
21
  5. Commit with a clear message
22
  6. Push and open a PR
23
 
@@ -27,6 +27,7 @@ Thanks for your interest in contributing! 🦞
27
  - No unnecessary dependencies
28
 
29
  ### Testing
 
30
  - Test with at least one LLM provider (Anthropic, OpenAI, or Google)
31
  - Test with and without Telegram enabled
32
  - Test with and without workspace backup enabled
@@ -38,7 +39,7 @@ Thanks for your interest in contributing! 🦞
38
  cp .env.example .env
39
  # Fill in your values
40
  docker build -t huggingclaw .
41
- docker run -p 7860:7860 --env-file .env huggingclaw
42
  ```
43
 
44
  ## Questions?
 
17
  1. Fork the repo
18
  2. Create a feature branch: `git checkout -b feature/my-feature`
19
  3. Make your changes
20
+ 4. Test locally with Docker: `docker build -t huggingclaw . && docker run -p 7861:7861 --env-file .env huggingclaw`
21
  5. Commit with a clear message
22
  6. Push and open a PR
23
 
 
27
  - No unnecessary dependencies
28
 
29
  ### Testing
30
+ - If touching routing or Docker startup, verify both `/app/` (OpenClaw UI) and `/terminal/` (JupyterLab terminal).
31
  - Test with at least one LLM provider (Anthropic, OpenAI, or Google)
32
  - Test with and without Telegram enabled
33
  - Test with and without workspace backup enabled
 
39
  cp .env.example .env
40
  # Fill in your values
41
  docker build -t huggingclaw .
42
+ docker run -p 7861:7861 --env-file .env huggingclaw
43
  ```
44
 
45
  ## Questions?
Dockerfile CHANGED
@@ -1,7 +1,11 @@
1
  # ════════════════════════════════════════════════════════════════
2
- # 🦞 HuggingClaw OpenClaw Gateway for HuggingFace Spaces
 
 
 
 
 
3
  # ════════════════════════════════════════════════════════════════
4
- # Multi-stage build: uses pre-built OpenClaw image for fast builds
5
 
6
  # ── Stage 1: Pull pre-built OpenClaw ──
7
  ARG OPENCLAW_VERSION=latest
@@ -10,8 +14,10 @@ FROM ghcr.io/openclaw/openclaw:${OPENCLAW_VERSION} AS openclaw
10
  # ── Stage 2: Runtime ──
11
  FROM node:22-slim
12
  ARG OPENCLAW_VERSION=latest
 
 
13
 
14
- # Install system dependencies
15
  RUN apt-get update && apt-get install -y \
16
  git \
17
  sudo \
@@ -45,9 +51,19 @@ RUN apt-get update && apt-get install -y \
45
  pip3 install --no-cache-dir --break-system-packages huggingface_hub && \
46
  rm -rf /var/lib/apt/lists/*
47
 
 
 
 
 
 
 
 
 
 
 
 
48
  # Reuse existing node user (UID 1000). Allow passwordless package-manager
49
- # commands only so runtime apt installs can be replayed after HF Space restarts
50
- # without granting unrestricted sudo access.
51
  RUN mkdir -p /home/node/app /home/node/.openclaw && \
52
  chown -R 1000:1000 /home/node && \
53
  printf '%s\n' \
@@ -60,8 +76,7 @@ RUN mkdir -p /home/node/app /home/node/.openclaw && \
60
  # Copy pre-built OpenClaw (skips npm install entirely — much faster!)
61
  COPY --from=openclaw --chown=1000:1000 /app /home/node/.openclaw/openclaw-app
62
 
63
- # Add Playwright in an isolated sidecar node_modules so we do not mutate the
64
- # bundled OpenClaw app dependency tree.
65
  RUN mkdir -p /home/node/browser-deps && \
66
  cd /home/node/browser-deps && \
67
  npm init -y && \
@@ -75,14 +90,22 @@ RUN ln -s /home/node/.openclaw/openclaw-app/openclaw.mjs /usr/local/bin/openclaw
75
  COPY --chown=1000:1000 cloudflare-proxy.js /opt/cloudflare-proxy.js
76
  COPY --chown=1000:1000 cloudflare-proxy-setup.py /home/node/app/cloudflare-proxy-setup.py
77
  COPY --chown=1000:1000 health-server.js /home/node/app/health-server.js
 
78
  COPY --chown=1000:1000 iframe-fix.cjs /home/node/app/iframe-fix.cjs
79
  COPY --chown=1000:1000 start.sh /home/node/app/start.sh
80
  COPY --chown=1000:1000 wa-guardian.js /home/node/app/wa-guardian.js
81
  COPY --chown=1000:1000 cloudflare-keepalive-setup.py /home/node/app/cloudflare-keepalive-setup.py
82
  COPY --chown=1000:1000 openclaw-sync.py /home/node/app/openclaw-sync.py
83
- RUN chmod +x /home/node/app/start.sh /home/node/app/cloudflare-proxy-setup.py /home/node/app/cloudflare-keepalive-setup.py /home/node/app/openclaw-sync.py
84
  COPY --chown=1000:1000 multi-provider-key-rotator.cjs /home/node/app/multi-provider-key-rotator.cjs
85
- RUN chmod +x /home/node/app/start.sh /home/node/app/cloudflare-proxy-setup.py /home/node/app/cloudflare-keepalive-setup.py /home/node/app/openclaw-sync.py /home/node/app/multi-provider-key-rotator.cjs
 
 
 
 
 
 
 
 
86
 
87
  USER node
88
 
@@ -94,10 +117,9 @@ ENV HOME=/home/node \
94
 
95
  WORKDIR /home/node/app
96
 
 
97
  EXPOSE 7861
98
 
99
- # health-server.js exposes /health on 7861 and proxies to the gateway on 7860.
100
- # 90s start period covers OpenClaw's plugin install + gateway boot on cold start.
101
  HEALTHCHECK --interval=30s --timeout=5s --start-period=90s \
102
  CMD curl -fsS http://localhost:7861/health || exit 1
103
 
 
1
  # ════════════════════════════════════════════════════════════════
2
+ # 🦞 HuggingClaw + 💻 JupyterLab Terminal
3
+ # ════════════════════════════════════════════════════════════════
4
+ # Port 7861 (exposed): Dashboard + reverse proxy
5
+ # / → HuggingClaw dashboard
6
+ # /app/ → OpenClaw gateway (internal :7860)
7
+ # /terminal/ → JupyterLab terminal (internal :8888)
8
  # ════════════════════════════════════════════════════════════════
 
9
 
10
  # ── Stage 1: Pull pre-built OpenClaw ──
11
  ARG OPENCLAW_VERSION=latest
 
14
  # ── Stage 2: Runtime ──
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 \
22
  git \
23
  sudo \
 
51
  pip3 install --no-cache-dir --break-system-packages huggingface_hub && \
52
  rm -rf /var/lib/apt/lists/*
53
 
54
+ # Install JupyterLab only when DEV_MODE is enabled (build-time)
55
+ # This avoids installing large packages when terminal is not needed
56
+ RUN if [ "${DEV_MODE}" = "true" ] || [ "${DEV_MODE}" = "1" ] || [ "${DEV_MODE}" = "yes" ] || [ "${DEV_MODE}" = "on" ]; then \
57
+ pip3 install --no-cache-dir --break-system-packages \
58
+ jupyterlab==4.5.7 \
59
+ tornado==6.5.5 \
60
+ ipywidgets==8.1.8 && \
61
+ # Copy login template into jupyter_server templates dir
62
+ 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; \
63
+ fi
64
+
65
  # Reuse existing node user (UID 1000). Allow passwordless package-manager
66
+ # commands only so runtime apt installs can be replayed after HF Space restarts.
 
67
  RUN mkdir -p /home/node/app /home/node/.openclaw && \
68
  chown -R 1000:1000 /home/node && \
69
  printf '%s\n' \
 
76
  # Copy pre-built OpenClaw (skips npm install entirely — much faster!)
77
  COPY --from=openclaw --chown=1000:1000 /app /home/node/.openclaw/openclaw-app
78
 
79
+ # Add Playwright in an isolated sidecar node_modules
 
80
  RUN mkdir -p /home/node/browser-deps && \
81
  cd /home/node/browser-deps && \
82
  npm init -y && \
 
90
  COPY --chown=1000:1000 cloudflare-proxy.js /opt/cloudflare-proxy.js
91
  COPY --chown=1000:1000 cloudflare-proxy-setup.py /home/node/app/cloudflare-proxy-setup.py
92
  COPY --chown=1000:1000 health-server.js /home/node/app/health-server.js
93
+ COPY --chown=1000:1000 login.html /home/node/app/login.html
94
  COPY --chown=1000:1000 iframe-fix.cjs /home/node/app/iframe-fix.cjs
95
  COPY --chown=1000:1000 start.sh /home/node/app/start.sh
96
  COPY --chown=1000:1000 wa-guardian.js /home/node/app/wa-guardian.js
97
  COPY --chown=1000:1000 cloudflare-keepalive-setup.py /home/node/app/cloudflare-keepalive-setup.py
98
  COPY --chown=1000:1000 openclaw-sync.py /home/node/app/openclaw-sync.py
 
99
  COPY --chown=1000:1000 multi-provider-key-rotator.cjs /home/node/app/multi-provider-key-rotator.cjs
100
+ COPY --chown=1000:1000 env-builder.html /home/node/app/env-builder.html
101
+ COPY --chown=1000:1000 env-builder.js /home/node/app/env-builder.js
102
+ COPY --chown=1000:1000 jupyter-devdata-sync.py /home/node/app/jupyter-devdata-sync.py
103
+ # login.html template is now copied inside the DEV_MODE install block above
104
+ RUN chmod +x /home/node/app/start.sh \
105
+ /home/node/app/cloudflare-proxy-setup.py \
106
+ /home/node/app/cloudflare-keepalive-setup.py \
107
+ /home/node/app/openclaw-sync.py \
108
+ /home/node/app/multi-provider-key-rotator.cjs
109
 
110
  USER node
111
 
 
117
 
118
  WORKDIR /home/node/app
119
 
120
+ # 7861 = public entrypoint (dashboard + proxy for both OpenClaw and JupyterLab)
121
  EXPOSE 7861
122
 
 
 
123
  HEALTHCHECK --interval=30s --timeout=5s --start-period=90s \
124
  CMD curl -fsS http://localhost:7861/health || exit 1
125
 
README.md CHANGED
@@ -1,12 +1,17 @@
1
  ---
2
  title: HuggingClaw
3
  emoji: 🦞
4
- colorFrom: blue
5
- colorTo: purple
6
  sdk: docker
7
  app_port: 7861
8
- pinned: true
9
  license: mit
 
 
 
 
 
10
  secrets:
11
  - name: LLM_API_KEY
12
  description: "Your LLM provider API key (e.g. Anthropic, OpenAI, Google, OpenRouter)."
@@ -14,6 +19,8 @@ secrets:
14
  description: "Model ID to use, e.g. google/gemini-2.5-flash or openai/gpt-4o."
15
  - name: GATEWAY_TOKEN
16
  description: "Strong token to secure your OpenClaw Control UI (generate: openssl rand -hex 32)."
 
 
17
  - name: CLOUDFLARE_WORKERS_TOKEN
18
  description: "Cloudflare API token — auto-creates a Worker proxy and KeepAlive monitor."
19
  - name: TELEGRAM_ALLOWED_USERS
@@ -32,7 +39,7 @@ secrets:
32
  [![HF Space](https://img.shields.io/badge/🤗%20HuggingFace-Space-blue?style=flat-square)](https://huggingface.co/spaces)
33
  [![OpenClaw](https://img.shields.io/badge/OpenClaw-Gateway-red?style=flat-square)](https://github.com/openclaw/openclaw)
34
 
35
- **Your always-on AI assistant — free, no server needed.** HuggingClaw runs [OpenClaw](https://openclaw.ai) on HuggingFace Spaces, giving you a 24/7 AI chat assistant on Telegram and WhatsApp. It works with *any* large language model (LLM) – Claude, ChatGPT, Gemini, etc. – and even supports custom models via [OpenRouter](https://openrouter.ai). Deploy in minutes on the free HF Spaces tier (2 vCPU, 16GB RAM, 50GB) with automatic workspace backup to a HuggingFace Dataset so your chat history and settings persist across restarts.
36
 
37
  ## Table of Contents
38
 
@@ -49,6 +56,8 @@ secrets:
49
  - [🤖 LLM Providers](#-llm-providers)
50
  - [💻 Local Development](#-local-development)
51
  - [🔗 CLI Access](#-cli-access)
 
 
52
  - [🏗️ Architecture](#-architecture)
53
  - [💓 Staying Alive](#-staying-alive)
54
  - [🐛 Troubleshooting](#-troubleshooting)
@@ -69,6 +78,7 @@ secrets:
69
  - 📊 **Visual Dashboard:** Beautiful Web UI to monitor uptime, sync status, and active models.
70
  - 🔔 **Webhooks:** Get notified on restarts or backup failures via standard webhooks.
71
  - 🔐 **Flexible Auth:** Secure the Control UI with either a gateway token or password.
 
72
  - 🏠 **100% HF-Native:** Runs entirely on HuggingFace’s free infrastructure (2 vCPU, 16GB RAM).
73
 
74
  ## 🎥 Video Tutorial
@@ -94,7 +104,7 @@ Navigate to your new Space's **Settings**, scroll down to the **Variables and se
94
  > [!TIP]
95
  > HuggingClaw is completely flexible! You only need these three secrets to get started. You can set other secrets later.
96
 
97
- Optional: if you want to pin a specific OpenClaw release instead of `latest`, add `OPENCLAW_VERSION` under **Variables** in your Space settings. For Docker Spaces, HF passes Variables as build args during image build, so this should be a Variable, not a Secret.
98
 
99
  ### Step 3: Deploy & Run
100
 
@@ -348,6 +358,30 @@ openclaw channels login --gateway https://YOUR_SPACE_NAME.hf.space
348
  # When prompted, enter your GATEWAY_TOKEN
349
  ```
350
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  ## 🏗️ Architecture
352
 
353
  HuggingClaw uses a multi-layered approach to ensure stability and persistence on Hugging Face's ephemeral infrastructure.
@@ -355,8 +389,9 @@ HuggingClaw uses a multi-layered approach to ensure stability and persistence on
355
  <details>
356
  <summary><b>Click to view technical details</b></summary>
357
 
358
- - **Dashboard (`/`)**: Management, monitoring, and keep-alive tools.
359
- - **Control UI (`/gateway`)**: Secure interface for managing agents and channels.
 
360
  - **Health Check (`/health`)**: Endpoint for uptime monitoring and readiness probes.
361
  - **Sync Engine**: Python background process managing HF Dataset persistence.
362
  - **Transparent Proxy**: Interceptor for requests to blocked domains (Telegram, etc.).
@@ -367,12 +402,15 @@ HuggingClaw uses a multi-layered approach to ensure stability and persistence on
367
  2. Resolve backup namespace and restore workspace from HF Dataset.
368
  3. Generate `openclaw.json` configuration.
369
  4. Launch background tasks (auto-sync, channel helpers).
370
- 5. Start OpenClaw gateway and listen for connections.
371
 
372
  </details>
373
 
374
  ## 🐛 Troubleshooting
375
 
 
 
 
376
  - **Missing secrets:** Ensure `LLM_API_KEY`, `LLM_MODEL`, and `GATEWAY_TOKEN` are set in your Space **Settings → Secrets**.
377
  - **Telegram bot issues:** Verify your `TELEGRAM_BOT_TOKEN`. Check Space logs for lines like `📱 Enabling Telegram`.
378
  - **Backup restore failing:** Make sure `HF_TOKEN` is valid and has write access to your HF account dataset. Set `HF_USERNAME` only if auto-detection is not available in your environment.
@@ -397,9 +435,9 @@ Similar projects by [@somratpro](https://github.com/somratpro) — all free, one
397
 
398
  ## 📚 Links
399
 
400
- - [OpenClaw Docs](https://docs.openclaw.ai)
401
- - [OpenClaw GitHub](https://github.com/openclaw/openclaw)
402
- - [HuggingFace Spaces Docs](https://huggingface.co/docs/hub/spaces)
403
 
404
  ## ❤️ Support
405
 
@@ -422,4 +460,4 @@ Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for gui
422
 
423
  MIT — see [LICENSE](LICENSE) for details.
424
 
425
- *Made with ❤️ by [@somratpro](https://github.com/somratpro) for the OpenClaw community.*
 
1
  ---
2
  title: HuggingClaw
3
  emoji: 🦞
4
+ colorFrom: red
5
+ colorTo: blue
6
  sdk: docker
7
  app_port: 7861
8
+ pinned: false
9
  license: mit
10
+ tags:
11
+ - openclaw
12
+ - jupyterlab
13
+ - terminal
14
+ - llm-gateway
15
  secrets:
16
  - name: LLM_API_KEY
17
  description: "Your LLM provider API key (e.g. Anthropic, OpenAI, Google, OpenRouter)."
 
19
  description: "Model ID to use, e.g. google/gemini-2.5-flash or openai/gpt-4o."
20
  - name: GATEWAY_TOKEN
21
  description: "Strong token to secure your OpenClaw Control UI (generate: openssl rand -hex 32)."
22
+ - name: JUPYTER_TOKEN
23
+ description: "Optional strong token for the JupyterLab terminal at /terminal/ (defaults to huggingface)."
24
  - name: CLOUDFLARE_WORKERS_TOKEN
25
  description: "Cloudflare API token — auto-creates a Worker proxy and KeepAlive monitor."
26
  - name: TELEGRAM_ALLOWED_USERS
 
39
  [![HF Space](https://img.shields.io/badge/🤗%20HuggingFace-Space-blue?style=flat-square)](https://huggingface.co/spaces)
40
  [![OpenClaw](https://img.shields.io/badge/OpenClaw-Gateway-red?style=flat-square)](https://github.com/openclaw/openclaw)
41
 
42
+ **Your always-on AI assistant — free, no server needed.** This merged Space runs [OpenClaw](https://openclaw.ai) plus a Hugging Face-style JupyterLab terminal on one HF Spaces port, giving you a 24/7 AI chat assistant on Telegram and WhatsApp. It works with *any* large language model (LLM) – Claude, ChatGPT, Gemini, etc. – and even supports custom models via [OpenRouter](https://openrouter.ai). Deploy in minutes on the free HF Spaces tier (2 vCPU, 16GB RAM, 50GB) with automatic workspace backup to a HuggingFace Dataset so your chat history and settings persist across restarts.
43
 
44
  ## Table of Contents
45
 
 
56
  - [🤖 LLM Providers](#-llm-providers)
57
  - [💻 Local Development](#-local-development)
58
  - [🔗 CLI Access](#-cli-access)
59
+ - [💻 JupyterLab Terminal](#-jupyterlab-terminal)
60
+ - [🔍 Merge Comparison](#-merge-comparison)
61
  - [🏗️ Architecture](#-architecture)
62
  - [💓 Staying Alive](#-staying-alive)
63
  - [🐛 Troubleshooting](#-troubleshooting)
 
78
  - 📊 **Visual Dashboard:** Beautiful Web UI to monitor uptime, sync status, and active models.
79
  - 🔔 **Webhooks:** Get notified on restarts or backup failures via standard webhooks.
80
  - 🔐 **Flexible Auth:** Secure the Control UI with either a gateway token or password.
81
+ - 💻 **Optional Dev Terminal:** JupyterLab is available at `/terminal/` only when `DEV_MODE=true` (disabled by default).
82
  - 🏠 **100% HF-Native:** Runs entirely on HuggingFace’s free infrastructure (2 vCPU, 16GB RAM).
83
 
84
  ## 🎥 Video Tutorial
 
104
  > [!TIP]
105
  > HuggingClaw is completely flexible! You only need these three secrets to get started. You can set other secrets later.
106
 
107
+ Optional: set `DEV_MODE=true` (Variable) to enable JupyterLab support and install Jupyter dependencies at build time. You can also set `JUPYTER_TOKEN` as a Secret to replace the default terminal token (`huggingface`). If you want to pin a specific OpenClaw release instead of `latest`, add `OPENCLAW_VERSION` under **Variables** in your Space settings. For Docker Spaces, HF passes Variables as build args during image build, so these should be Variables, not Secrets (except tokens).
108
 
109
  ### Step 3: Deploy & Run
110
 
 
358
  # When prompted, enter your GATEWAY_TOKEN
359
  ```
360
 
361
+ ## 💻 JupyterLab Terminal
362
+
363
+ The merged Space includes the Hugging Face JupyterLab template behavior inside the same container:
364
+
365
+ | Path | Service | Internal Port | Notes |
366
+ | :--- | :--- | :--- | :--- |
367
+ | `/` | HuggingClaw dashboard | `7861` | Public HF Spaces entrypoint |
368
+ | `/app/` | OpenClaw Control UI | `7860` | Mounted behind the local reverse proxy |
369
+ | `/terminal/` | JupyterLab terminal (DEV_MODE only) | `8888` | Available only when `DEV_MODE=true`; token login uses `JUPYTER_TOKEN` (default `huggingface`) |
370
+
371
+ When enabled, the terminal notebook root is `/home/node`, so you can inspect HuggingClaw files, logs, workspace state, and runtime scripts from the browser.
372
+
373
+ > [!IMPORTANT]
374
+ > For real deployments, set a strong `JUPYTER_TOKEN` secret. The `huggingface` default exists only to match the duplicateable Hugging Face JupyterLab template.
375
+
376
+ ## 🔍 Merge Comparison
377
+
378
+ This repository is a merge of two sources:
379
+
380
+ - `anurag162008/HuggingClaw`: OpenClaw gateway, dashboard, Cloudflare proxy/keep-alive, Telegram/WhatsApp helpers, backup sync, key rotation, docs, and security metadata.
381
+ - Hugging Face `SpacesExamples/jupyterlab` template: JupyterLab Docker behavior, token login UX, Hugging Face-branded login template, pinned Jupyter packages, and Git LFS defaults for large model/data artifacts.
382
+
383
+ The main merge-specific change is the single-port router: HF Spaces exposes `7861`, while the router keeps OpenClaw at `/app/` and JupyterLab at `/terminal/` without leaking internal redirects such as `http://127.0.0.1:8888/...`.
384
+
385
  ## 🏗️ Architecture
386
 
387
  HuggingClaw uses a multi-layered approach to ensure stability and persistence on Hugging Face's ephemeral infrastructure.
 
389
  <details>
390
  <summary><b>Click to view technical details</b></summary>
391
 
392
+ - **Dashboard (`/`)**: Management, monitoring, and keep-alive tools (terminal controls appear only in DEV mode).
393
+ - **Control UI (`/app/`)**: Secure interface for managing agents and channels, proxied to the OpenClaw gateway on internal port `7860`.
394
+ - **JupyterLab Terminal (`/terminal/`)**: Browser terminal/notebook server on internal port `8888` (DEV mode only).
395
  - **Health Check (`/health`)**: Endpoint for uptime monitoring and readiness probes.
396
  - **Sync Engine**: Python background process managing HF Dataset persistence.
397
  - **Transparent Proxy**: Interceptor for requests to blocked domains (Telegram, etc.).
 
402
  2. Resolve backup namespace and restore workspace from HF Dataset.
403
  3. Generate `openclaw.json` configuration.
404
  4. Launch background tasks (auto-sync, channel helpers).
405
+ 5. Start the local dashboard/reverse proxy and OpenClaw gateway (JupyterLab starts only when `DEV_MODE=true`).
406
 
407
  </details>
408
 
409
  ## 🐛 Troubleshooting
410
 
411
+ - **Private Space 404:** If your Space is private, raw `https://<space>.hf.space/app/` or `/terminal/` links can show Hugging Face's own 404 page when opened outside the embedded App session. Open the Space's **App** tab first, then use the in-page dashboard buttons for `/app/` and `/terminal/`.
412
+ - **Terminal 404 or redirect loop:** Open `/terminal/` with the trailing slash from the dashboard/App tab, rebuild after Dockerfile changes, and confirm `JUPYTER_TOKEN` is set correctly if you changed the default.
413
+ - **Control UI 404:** Open `/app/` with the trailing slash from the dashboard/App tab; the reverse proxy rewrites backend redirects into this mount path.
414
  - **Missing secrets:** Ensure `LLM_API_KEY`, `LLM_MODEL`, and `GATEWAY_TOKEN` are set in your Space **Settings → Secrets**.
415
  - **Telegram bot issues:** Verify your `TELEGRAM_BOT_TOKEN`. Check Space logs for lines like `📱 Enabling Telegram`.
416
  - **Backup restore failing:** Make sure `HF_TOKEN` is valid and has write access to your HF account dataset. Set `HF_USERNAME` only if auto-detection is not available in your environment.
 
435
 
436
  ## 📚 Links
437
 
438
+ - [OpenClaw Docs](https://docs.openclaw.ai)
439
+ - [OpenClaw GitHub](https://github.com/openclaw/openclaw)
440
+ - [HuggingFace Spaces Docs](https://huggingface.co/docs/hub/spaces)
441
 
442
  ## ❤️ Support
443
 
 
460
 
461
  MIT — see [LICENSE](LICENSE) for details.
462
 
463
+ *Made with ❤️ by [@somratpro](https://github.com/somratpro) for the OpenClaw community.*
SECURITY.md CHANGED
@@ -16,6 +16,7 @@ When deploying HuggingClaw:
16
 
17
  - **Set your Space to Private** — prevents unauthorized access to your gateway
18
  - **Use a strong `GATEWAY_TOKEN`** — generate with `openssl rand -hex 32`
 
19
  - **Keep your HF token scoped** — use fine-grained tokens with minimum permissions
20
  - **Don't commit `.env` files** — the `.gitignore` already excludes them
21
  - **Use `TELEGRAM_ALLOWED_USERS`** — restricts bot access to your account only
 
16
 
17
  - **Set your Space to Private** — prevents unauthorized access to your gateway
18
  - **Use a strong `GATEWAY_TOKEN`** — generate with `openssl rand -hex 32`
19
+ - **Set a strong `JUPYTER_TOKEN`** — the `/terminal/` JupyterLab login defaults to `huggingface` only for template convenience
20
  - **Keep your HF token scoped** — use fine-grained tokens with minimum permissions
21
  - **Don't commit `.env` files** — the `.gitignore` already excludes them
22
  - **Use `TELEGRAM_ALLOWED_USERS`** — restricts bot access to your account only
cloudflare-proxy.js CHANGED
@@ -32,6 +32,7 @@ const DEFAULT_PROXY_DOMAINS = [
32
  "open.tiktokapis.com", "oauth.reddit.com",
33
  "youtube.com", "www.youtube.com",
34
  "api.openai.com",
 
35
  "api.resend.com", "api.sendgrid.com", "api.mailgun.net",
36
  "googleapis.com", "google.com", "googleusercontent.com", "gstatic.com",
37
  ];
 
32
  "open.tiktokapis.com", "oauth.reddit.com",
33
  "youtube.com", "www.youtube.com",
34
  "api.openai.com",
35
+ "integrate.api.nvidia.com", "api.nvidia.com",
36
  "api.resend.com", "api.sendgrid.com", "api.mailgun.net",
37
  "googleapis.com", "google.com", "googleusercontent.com", "gstatic.com",
38
  ];
env-builder.html ADDED
@@ -0,0 +1,819 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>HuggingClaw · ENV Builder</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
10
+
11
+ <style>
12
+ /* ── Reset & Tokens ── */
13
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
14
+
15
+ :root {
16
+ --bg: #0b0c0f;
17
+ --bg2: #111318;
18
+ --bg3: #181c23;
19
+ --bg4: #1e2330;
20
+ --border: #252b38;
21
+ --border2: #2e3648;
22
+ --amber: #f5a623;
23
+ --amber2: #ffbe55;
24
+ --amber-dim: rgba(245,166,35,.12);
25
+ --amber-glow:rgba(245,166,35,.22);
26
+ --green: #3dd68c;
27
+ --red: #f05f5f;
28
+ --blue: #5b8af5;
29
+ --text: #e4e8f0;
30
+ --text2: #8d97ad;
31
+ --text3: #535f76;
32
+ --mono: 'JetBrains Mono', monospace;
33
+ --sans: 'Syne', sans-serif;
34
+ --r: 8px;
35
+ --r2: 12px;
36
+ --sidebar-w: 220px;
37
+ --panel-w: 340px;
38
+ }
39
+
40
+ html { scroll-behavior: smooth; }
41
+
42
+ body {
43
+ font-family: var(--sans);
44
+ background: var(--bg);
45
+ color: var(--text);
46
+ min-height: 100vh;
47
+ display: flex;
48
+ flex-direction: column;
49
+ overflow-x: hidden;
50
+ }
51
+
52
+ /* ── Topbar ── */
53
+ .topbar {
54
+ position: sticky;
55
+ top: 0;
56
+ z-index: 100;
57
+ height: 52px;
58
+ background: rgba(11,12,15,.9);
59
+ backdrop-filter: blur(14px);
60
+ border-bottom: 1px solid var(--border);
61
+ display: flex;
62
+ align-items: center;
63
+ padding: 0 20px;
64
+ gap: 16px;
65
+ flex-shrink: 0;
66
+ }
67
+
68
+ .topbar-logo {
69
+ display: flex;
70
+ align-items: center;
71
+ gap: 10px;
72
+ flex-shrink: 0;
73
+ }
74
+
75
+ .topbar-logo svg { width: 26px; height: 26px; }
76
+
77
+ .topbar-wordmark {
78
+ font-weight: 800;
79
+ font-size: 14px;
80
+ letter-spacing: -.2px;
81
+ color: var(--text);
82
+ white-space: nowrap;
83
+ }
84
+
85
+ .topbar-wordmark em {
86
+ color: var(--amber);
87
+ font-style: normal;
88
+ }
89
+
90
+ .topbar-divider {
91
+ width: 1px;
92
+ height: 22px;
93
+ background: var(--border2);
94
+ }
95
+
96
+ .topbar-title {
97
+ font-size: 12px;
98
+ font-weight: 600;
99
+ color: var(--text2);
100
+ letter-spacing: .5px;
101
+ text-transform: uppercase;
102
+ }
103
+
104
+ .topbar-spacer { flex: 1; }
105
+
106
+ .topbar-pill {
107
+ font-family: var(--mono);
108
+ font-size: 10px;
109
+ color: var(--amber);
110
+ background: var(--amber-dim);
111
+ border: 1px solid var(--amber-glow);
112
+ border-radius: 20px;
113
+ padding: 3px 10px;
114
+ letter-spacing: .5px;
115
+ }
116
+
117
+ /* ── Layout ── */
118
+ .layout {
119
+ display: flex;
120
+ flex: 1;
121
+ min-height: 0;
122
+ height: calc(100vh - 52px);
123
+ }
124
+
125
+ /* ── Sidebar ── */
126
+ .sidebar-wrap {
127
+ width: var(--sidebar-w);
128
+ flex-shrink: 0;
129
+ border-right: 1px solid var(--border);
130
+ background: var(--bg2);
131
+ display: flex;
132
+ flex-direction: column;
133
+ overflow: hidden;
134
+ }
135
+
136
+ .sidebar-scroll {
137
+ flex: 1;
138
+ overflow-y: auto;
139
+ padding: 14px 10px;
140
+ }
141
+
142
+ .sidebar-scroll::-webkit-scrollbar { width: 4px; }
143
+ .sidebar-scroll::-webkit-scrollbar-track { background: transparent; }
144
+ .sidebar-scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
145
+
146
+ /* sb-label rendered by JS */
147
+ .sb-label {
148
+ font-size: 9px;
149
+ font-weight: 700;
150
+ text-transform: uppercase;
151
+ letter-spacing: 1.2px;
152
+ color: var(--text3);
153
+ padding: 0 8px 10px;
154
+ }
155
+
156
+ .nav-btn {
157
+ width: 100%;
158
+ display: flex;
159
+ align-items: center;
160
+ gap: 8px;
161
+ padding: 8px 10px;
162
+ border: none;
163
+ background: transparent;
164
+ cursor: pointer;
165
+ border-radius: var(--r);
166
+ text-align: left;
167
+ color: var(--text2);
168
+ font-family: var(--sans);
169
+ font-size: 12.5px;
170
+ font-weight: 500;
171
+ transition: background .15s, color .15s;
172
+ margin-bottom: 2px;
173
+ }
174
+
175
+ .nav-btn:hover { background: var(--bg3); color: var(--text); }
176
+ .nav-btn.active {
177
+ background: var(--amber-dim);
178
+ color: var(--amber);
179
+ border: 1px solid var(--amber-glow);
180
+ }
181
+
182
+ .nav-icon { font-size: 13px; flex-shrink: 0; }
183
+ .nav-label { flex: 1; }
184
+ .nav-count {
185
+ font-family: var(--mono);
186
+ font-size: 10px;
187
+ font-weight: 600;
188
+ color: var(--text3);
189
+ background: var(--bg3);
190
+ border-radius: 10px;
191
+ padding: 1px 6px;
192
+ min-width: 20px;
193
+ text-align: center;
194
+ transition: background .2s, color .2s;
195
+ }
196
+ .nav-btn.active .nav-count {
197
+ background: var(--amber-glow);
198
+ color: var(--amber2);
199
+ }
200
+
201
+ /* ── Main ── */
202
+ .main {
203
+ flex: 1;
204
+ display: flex;
205
+ flex-direction: column;
206
+ min-width: 0;
207
+ overflow: hidden;
208
+ }
209
+
210
+ /* ── Toolbar ── */
211
+ .toolbar {
212
+ display: flex;
213
+ align-items: center;
214
+ gap: 10px;
215
+ padding: 12px 20px;
216
+ border-bottom: 1px solid var(--border);
217
+ background: var(--bg2);
218
+ flex-shrink: 0;
219
+ flex-wrap: wrap;
220
+ }
221
+
222
+ .search-wrap {
223
+ position: relative;
224
+ flex: 1;
225
+ min-width: 160px;
226
+ max-width: 340px;
227
+ }
228
+
229
+ .search-icon {
230
+ position: absolute;
231
+ left: 10px;
232
+ top: 50%;
233
+ transform: translateY(-50%);
234
+ color: var(--text3);
235
+ pointer-events: none;
236
+ font-size: 12px;
237
+ }
238
+
239
+ #search {
240
+ width: 100%;
241
+ background: var(--bg3);
242
+ border: 1px solid var(--border2);
243
+ border-radius: var(--r);
244
+ padding: 7px 10px 7px 30px;
245
+ font-family: var(--mono);
246
+ font-size: 12px;
247
+ color: var(--text);
248
+ outline: none;
249
+ transition: border-color .15s;
250
+ }
251
+
252
+ #search:focus { border-color: var(--amber); }
253
+ #search::placeholder { color: var(--text3); }
254
+
255
+ .tb-sep {
256
+ width: 1px;
257
+ height: 24px;
258
+ background: var(--border2);
259
+ }
260
+
261
+ .btn {
262
+ display: inline-flex;
263
+ align-items: center;
264
+ gap: 5px;
265
+ padding: 6px 13px;
266
+ border-radius: var(--r);
267
+ border: 1px solid var(--border2);
268
+ background: var(--bg3);
269
+ color: var(--text2);
270
+ font-family: var(--sans);
271
+ font-size: 11.5px;
272
+ font-weight: 600;
273
+ cursor: pointer;
274
+ transition: all .15s;
275
+ white-space: nowrap;
276
+ }
277
+
278
+ .btn:hover { background: var(--bg4); color: var(--text); border-color: var(--border2); }
279
+
280
+ .btn-amber {
281
+ background: var(--amber);
282
+ color: #0b0c0f;
283
+ border-color: var(--amber);
284
+ }
285
+ .btn-amber:hover { background: var(--amber2); border-color: var(--amber2); }
286
+
287
+ .btn-ghost {
288
+ background: transparent;
289
+ border-color: transparent;
290
+ color: var(--text3);
291
+ }
292
+ .btn-ghost:hover { background: var(--bg3); color: var(--text2); border-color: var(--border2); }
293
+
294
+ /* ── Content area ── */
295
+ .content-wrap {
296
+ flex: 1;
297
+ display: flex;
298
+ min-height: 0;
299
+ overflow: hidden;
300
+ }
301
+
302
+ /* ── Sections scroll ── */
303
+ .sections-scroll {
304
+ flex: 1;
305
+ overflow-y: auto;
306
+ padding: 16px 20px 80px;
307
+ min-width: 0;
308
+ }
309
+
310
+ .sections-scroll::-webkit-scrollbar { width: 5px; }
311
+ .sections-scroll::-webkit-scrollbar-track { background: transparent; }
312
+ .sections-scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
313
+
314
+ /* ── Section ── */
315
+ .sec { margin-bottom: 28px; }
316
+ .sec.sec-hidden { display: none !important; }
317
+
318
+ .sec-header {
319
+ display: flex;
320
+ align-items: center;
321
+ gap: 8px;
322
+ margin-bottom: 12px;
323
+ }
324
+
325
+ .sec-icon { font-size: 14px; }
326
+ .sec-title {
327
+ font-size: 11px;
328
+ font-weight: 700;
329
+ text-transform: uppercase;
330
+ letter-spacing: 1.2px;
331
+ color: var(--text3);
332
+ }
333
+
334
+ .sec-line {
335
+ flex: 1;
336
+ height: 1px;
337
+ background: var(--border);
338
+ }
339
+
340
+ .cards {
341
+ display: grid;
342
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
343
+ gap: 10px;
344
+ }
345
+
346
+ /* ── ENV Card ── */
347
+ .env-card {
348
+ background: var(--bg2);
349
+ border: 1px solid var(--border);
350
+ border-radius: var(--r2);
351
+ padding: 12px;
352
+ transition: border-color .2s, background .2s;
353
+ }
354
+
355
+ .env-card:hover { border-color: var(--border2); }
356
+ .env-card.hidden { display: none; }
357
+
358
+ .env-card.selected {
359
+ border-color: var(--amber-glow);
360
+ background: linear-gradient(135deg, var(--bg2) 80%, rgba(245,166,35,.04));
361
+ }
362
+
363
+ .card-top {
364
+ display: flex;
365
+ align-items: flex-start;
366
+ gap: 9px;
367
+ margin-bottom: 9px;
368
+ }
369
+
370
+ .card-check {
371
+ width: 15px;
372
+ height: 15px;
373
+ accent-color: var(--amber);
374
+ flex-shrink: 0;
375
+ margin-top: 2px;
376
+ cursor: pointer;
377
+ }
378
+
379
+ .card-info { flex: 1; min-width: 0; }
380
+
381
+ .card-key {
382
+ font-family: var(--mono);
383
+ font-size: 11.5px;
384
+ font-weight: 600;
385
+ color: var(--text);
386
+ letter-spacing: .3px;
387
+ white-space: nowrap;
388
+ overflow: hidden;
389
+ text-overflow: ellipsis;
390
+ }
391
+
392
+ .card-lbl {
393
+ font-size: 11px;
394
+ color: var(--text3);
395
+ margin-top: 2px;
396
+ line-height: 1.35;
397
+ }
398
+
399
+ .badge {
400
+ flex-shrink: 0;
401
+ font-family: var(--mono);
402
+ font-size: 9px;
403
+ font-weight: 700;
404
+ text-transform: uppercase;
405
+ letter-spacing: .6px;
406
+ padding: 2px 7px;
407
+ border-radius: 20px;
408
+ }
409
+
410
+ .badge-s {
411
+ background: rgba(240,95,95,.12);
412
+ color: var(--red);
413
+ border: 1px solid rgba(240,95,95,.25);
414
+ }
415
+
416
+ .badge-f {
417
+ background: rgba(61,214,140,.1);
418
+ color: var(--green);
419
+ border: 1px solid rgba(61,214,140,.2);
420
+ }
421
+
422
+ /* ── Card inputs ── */
423
+ .card-input { position: relative; }
424
+
425
+ .card-input input[type="text"],
426
+ .card-input input[type="password"],
427
+ .card-input input[type="number"],
428
+ .card-input textarea,
429
+ .card-input select {
430
+ width: 100%;
431
+ background: var(--bg3);
432
+ border: 1px solid var(--border);
433
+ border-radius: var(--r);
434
+ padding: 7px 10px;
435
+ font-family: var(--mono);
436
+ font-size: 11.5px;
437
+ color: var(--text);
438
+ outline: none;
439
+ transition: border-color .15s;
440
+ resize: vertical;
441
+ }
442
+
443
+ .card-input input[type="text"]:focus,
444
+ .card-input input[type="password"]:focus,
445
+ .card-input input[type="number"]:focus,
446
+ .card-input textarea:focus,
447
+ .card-input select:focus {
448
+ border-color: var(--amber);
449
+ }
450
+
451
+ .card-input textarea { min-height: 64px; }
452
+ .card-input select {
453
+ cursor: pointer;
454
+ appearance: none;
455
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238d97ad' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
456
+ background-repeat: no-repeat;
457
+ background-position: right 10px center;
458
+ padding-right: 28px;
459
+ }
460
+
461
+ .card-input optgroup { color: var(--text2); font-weight: 600; }
462
+ .card-input option { color: var(--text); background: var(--bg3); }
463
+
464
+ /* ── Toggle ── */
465
+ .toggle-shell { display: flex; align-items: center; gap: 8px; }
466
+ .tog {
467
+ padding: 5px 14px;
468
+ border-radius: 20px;
469
+ border: 1px solid var(--border2);
470
+ background: var(--bg3);
471
+ color: var(--text3);
472
+ font-family: var(--mono);
473
+ font-size: 11px;
474
+ font-weight: 700;
475
+ cursor: pointer;
476
+ transition: all .18s;
477
+ letter-spacing: .5px;
478
+ }
479
+ .tog.on {
480
+ background: rgba(61,214,140,.15);
481
+ border-color: rgba(61,214,140,.4);
482
+ color: var(--green);
483
+ }
484
+
485
+ /* ── Picker shell ── */
486
+ .picker-shell { display: flex; flex-direction: column; gap: 6px; }
487
+ .picker-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
488
+
489
+ .picker-select {
490
+ flex: 1;
491
+ min-width: 0;
492
+ padding: 6px 28px 6px 8px !important;
493
+ font-size: 11px !important;
494
+ }
495
+
496
+ .mini-btn {
497
+ padding: 5px 9px;
498
+ border-radius: var(--r);
499
+ border: 1px solid var(--border2);
500
+ background: var(--bg3);
501
+ color: var(--text2);
502
+ font-family: var(--mono);
503
+ font-size: 10px;
504
+ font-weight: 600;
505
+ cursor: pointer;
506
+ transition: all .15s;
507
+ white-space: nowrap;
508
+ }
509
+ .mini-btn:hover { background: var(--bg4); color: var(--text); }
510
+
511
+ /* ── Right panel ── */
512
+ .right-panel {
513
+ width: var(--panel-w);
514
+ flex-shrink: 0;
515
+ border-left: 1px solid var(--border);
516
+ background: var(--bg2);
517
+ display: flex;
518
+ flex-direction: column;
519
+ overflow: hidden;
520
+ }
521
+
522
+ .panel-scroll {
523
+ flex: 1;
524
+ overflow-y: auto;
525
+ padding: 16px;
526
+ display: flex;
527
+ flex-direction: column;
528
+ gap: 16px;
529
+ }
530
+
531
+ .panel-scroll::-webkit-scrollbar { width: 4px; }
532
+ .panel-scroll::-webkit-scrollbar-track { background: transparent; }
533
+ .panel-scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
534
+
535
+ /* ── Panel block ── */
536
+ .pblock {
537
+ background: var(--bg3);
538
+ border: 1px solid var(--border);
539
+ border-radius: var(--r2);
540
+ overflow: hidden;
541
+ }
542
+
543
+ .pblock-head {
544
+ display: flex;
545
+ align-items: center;
546
+ justify-content: space-between;
547
+ padding: 10px 14px;
548
+ border-bottom: 1px solid var(--border);
549
+ }
550
+
551
+ .pblock-title {
552
+ font-size: 10.5px;
553
+ font-weight: 700;
554
+ text-transform: uppercase;
555
+ letter-spacing: 1px;
556
+ color: var(--text3);
557
+ display: flex;
558
+ align-items: center;
559
+ gap: 6px;
560
+ }
561
+
562
+ .pblock-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; }
563
+
564
+ .pblock-body textarea,
565
+ .pblock-body input[type="text"] {
566
+ width: 100%;
567
+ background: var(--bg);
568
+ border: 1px solid var(--border);
569
+ border-radius: var(--r);
570
+ padding: 8px 10px;
571
+ font-family: var(--mono);
572
+ font-size: 10.5px;
573
+ color: var(--text2);
574
+ outline: none;
575
+ resize: vertical;
576
+ transition: border-color .15s;
577
+ }
578
+
579
+ .pblock-body textarea:focus,
580
+ .pblock-body input[type="text"]:focus {
581
+ border-color: var(--amber);
582
+ color: var(--text);
583
+ }
584
+
585
+ #importText { min-height: 80px; }
586
+ #bundleOut { min-height: 60px; color: var(--amber2); }
587
+ #envLineOut { font-size: 10px; }
588
+
589
+ .row-btns { display: flex; gap: 6px; flex-wrap: wrap; }
590
+
591
+ /* ── Summary ── */
592
+ #summary {
593
+ font-size: 11.5px;
594
+ color: var(--text2);
595
+ line-height: 1.6;
596
+ }
597
+
598
+ #summary strong {
599
+ font-size: 15px;
600
+ color: var(--amber);
601
+ font-family: var(--mono);
602
+ }
603
+
604
+ .sum-keys {
605
+ margin-top: 8px;
606
+ display: flex;
607
+ flex-wrap: wrap;
608
+ gap: 4px;
609
+ }
610
+
611
+ .sum-key {
612
+ font-family: var(--mono);
613
+ font-size: 9.5px;
614
+ color: var(--text2);
615
+ background: var(--bg4);
616
+ border: 1px solid var(--border2);
617
+ border-radius: 4px;
618
+ padding: 2px 6px;
619
+ }
620
+
621
+ /* ── Custom Env section ── */
622
+ #customSec {
623
+ margin-top: 8px;
624
+ }
625
+
626
+ .custom-row {
627
+ display: flex;
628
+ gap: 8px;
629
+ align-items: center;
630
+ margin-bottom: 8px;
631
+ }
632
+
633
+ .custom-row input {
634
+ flex: 1;
635
+ background: var(--bg3);
636
+ border: 1px solid var(--border);
637
+ border-radius: var(--r);
638
+ padding: 7px 10px;
639
+ font-family: var(--mono);
640
+ font-size: 11px;
641
+ color: var(--text);
642
+ outline: none;
643
+ transition: border-color .15s;
644
+ min-width: 0;
645
+ }
646
+
647
+ .custom-row input:focus { border-color: var(--amber); }
648
+ .custom-row input:first-child { flex: 0 0 40%; }
649
+
650
+ /* ── Toast ── */
651
+ #toast {
652
+ position: fixed;
653
+ bottom: 24px;
654
+ left: 50%;
655
+ transform: translateX(-50%) translateY(20px);
656
+ background: var(--bg4);
657
+ border: 1px solid var(--border2);
658
+ color: var(--amber);
659
+ font-family: var(--mono);
660
+ font-size: 12px;
661
+ font-weight: 600;
662
+ padding: 9px 20px;
663
+ border-radius: 30px;
664
+ z-index: 9999;
665
+ opacity: 0;
666
+ transition: opacity .2s, transform .2s;
667
+ pointer-events: none;
668
+ box-shadow: 0 8px 32px rgba(0,0,0,.5);
669
+ }
670
+
671
+ #toast.show {
672
+ opacity: 1;
673
+ transform: translateX(-50%) translateY(0);
674
+ }
675
+
676
+ /* ── Scrollbar global ── */
677
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
678
+ ::-webkit-scrollbar-track { background: transparent; }
679
+ ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
680
+
681
+ /* ── Responsive ── */
682
+ @media (max-width: 900px) {
683
+ :root { --panel-w: 280px; --sidebar-w: 180px; }
684
+ }
685
+
686
+ @media (max-width: 700px) {
687
+ .right-panel { display: none; }
688
+ :root { --sidebar-w: 160px; }
689
+ }
690
+
691
+ @media (max-width: 520px) {
692
+ .sidebar-wrap { display: none; }
693
+ .topbar-divider, .topbar-title { display: none; }
694
+ }
695
+ </style>
696
+ </head>
697
+
698
+ <body>
699
+
700
+ <!-- ── Topbar ── -->
701
+ <header class="topbar">
702
+ <div class="topbar-logo">
703
+ <svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
704
+ <rect width="64" height="64" rx="14" fill="#1a1f2e"/>
705
+ <text x="10" y="46" font-size="40" font-family="monospace">🤗</text>
706
+ <circle cx="46" cy="20" r="10" fill="#f5a623"/>
707
+ <text x="40" y="25" font-size="14" font-family="monospace" fill="#0b0c0f">⚡</text>
708
+ </svg>
709
+ <span class="topbar-wordmark">Hugging<em>Claw</em></span>
710
+ </div>
711
+ <div class="topbar-divider"></div>
712
+ <span class="topbar-title">ENV Builder</span>
713
+ <div class="topbar-spacer"></div>
714
+ <span class="topbar-pill">v2025</span>
715
+ </header>
716
+
717
+ <!-- ── Layout ── -->
718
+ <div class="layout">
719
+
720
+ <!-- ── Sidebar ── -->
721
+ <aside class="sidebar-wrap">
722
+ <div class="sidebar-scroll">
723
+ <div id="sidebar"></div>
724
+ </div>
725
+ </aside>
726
+
727
+ <!-- ── Main ── -->
728
+ <main class="main">
729
+
730
+ <!-- toolbar -->
731
+ <div class="toolbar">
732
+ <div class="search-wrap">
733
+ <span class="search-icon">⌕</span>
734
+ <input id="search" type="text" placeholder="Search variables…" autocomplete="off" spellcheck="false">
735
+ </div>
736
+
737
+ <div class="tb-sep"></div>
738
+
739
+ <button id="selectCommon" class="btn">★ Common</button>
740
+ <button id="selectVisible" class="btn">☑ Visible</button>
741
+ <button id="clearAll" class="btn btn-ghost">✕ Clear</button>
742
+ </div>
743
+
744
+ <!-- content row -->
745
+ <div class="content-wrap">
746
+
747
+ <!-- sections -->
748
+ <div class="sections-scroll">
749
+ <div id="sections"></div>
750
+
751
+ <!-- Custom Env section -->
752
+ <div id="customSec" class="sec" data-section="Custom Env">
753
+ <div class="sec-header">
754
+ <span class="sec-icon">🔧</span>
755
+ <span class="sec-title">Custom Env</span>
756
+ <div class="sec-line"></div>
757
+ </div>
758
+ <div id="customRows"></div>
759
+ <button id="addCustom" class="btn" style="margin-top:6px;">+ Add variable</button>
760
+ </div>
761
+ </div>
762
+
763
+ <!-- right panel -->
764
+ <aside class="right-panel">
765
+ <div class="panel-scroll">
766
+
767
+ <!-- Summary -->
768
+ <div class="pblock">
769
+ <div class="pblock-head">
770
+ <span class="pblock-title">📊 Summary</span>
771
+ </div>
772
+ <div class="pblock-body">
773
+ <div id="summary">No variables selected yet.</div>
774
+ </div>
775
+ </div>
776
+
777
+ <!-- Output -->
778
+ <div class="pblock">
779
+ <div class="pblock-head">
780
+ <span class="pblock-title">📦 Bundle Output</span>
781
+ </div>
782
+ <div class="pblock-body">
783
+ <textarea id="bundleOut" placeholder="Your encoded bundle will appear here…" readonly spellcheck="false"></textarea>
784
+ <input type="text" id="envLineOut" placeholder="HUGGINGCLAW_ENV_BUNDLE=…" readonly spellcheck="false">
785
+ <div class="row-btns">
786
+ <button id="copyBundle" class="btn btn-amber">⎘ Bundle</button>
787
+ <button id="copyEnvLine" class="btn">⎘ Env Line</button>
788
+ <button id="copyJson" class="btn">⎘ JSON</button>
789
+ <button id="applyBundle" class="btn btn-ghost">↺ Apply</button>
790
+ </div>
791
+ </div>
792
+ </div>
793
+
794
+ <!-- Import -->
795
+ <div class="pblock">
796
+ <div class="pblock-head">
797
+ <span class="pblock-title">📥 Import</span>
798
+ </div>
799
+ <div class="pblock-body">
800
+ <textarea id="importText" placeholder="Paste .env, JSON, or HUGGINGCLAW_ENV_BUNDLE=… here" spellcheck="false"></textarea>
801
+ <button id="applyImport" class="btn btn-amber" style="width:100%;">↓ Import & Apply</button>
802
+ </div>
803
+ </div>
804
+
805
+ </div>
806
+ </aside>
807
+
808
+ </div><!-- /content-wrap -->
809
+ </main>
810
+ </div><!-- /layout -->
811
+
812
+ <!-- Toast -->
813
+ <div id="toast">Copied ✓</div>
814
+
815
+ <!-- env-builder.js provides MODEL_CATALOGS, FIELDS, ICONS and all logic -->
816
+ <script src="env-builder.js"></script>
817
+
818
+ </body>
819
+ </html>
env-builder.js ADDED
@@ -0,0 +1,2341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const MODEL_CATALOGS = {
2
+ "LLM_MODEL": {
3
+ "Anthropic": [
4
+ "claude-opus-4-7",
5
+ "claude-opus-4-6",
6
+ "claude-opus-4-5",
7
+ "claude-opus-4-1",
8
+ "claude-sonnet-4-7",
9
+ "claude-sonnet-4-6",
10
+ "claude-sonnet-4-5",
11
+ "claude-haiku-4-5",
12
+ "claude-haiku-4-5-20251001",
13
+ "claude-haiku-3-5"
14
+ ],
15
+ "OpenAI": [
16
+ "gpt-5.4-pro",
17
+ "gpt-5.4",
18
+ "gpt-5.4-mini",
19
+ "gpt-5.4-nano",
20
+ "gpt-5.1",
21
+ "gpt-5",
22
+ "gpt-4.1",
23
+ "gpt-4.1-mini",
24
+ "gpt-4o",
25
+ "gpt-4o-mini",
26
+ "o3",
27
+ "o4-mini"
28
+ ],
29
+ "Gemini": [
30
+ "gemini-3.1-pro-preview",
31
+ "gemini-3.1-flash-preview",
32
+ "gemini-3-flash-preview",
33
+ "gemini-2.5-pro",
34
+ "gemini-2.5-flash",
35
+ "gemini-2.0-flash",
36
+ "gemini-flash-latest",
37
+ "gemini-pro-latest"
38
+ ],
39
+ "DeepSeek": [
40
+ "deepseek-v4-pro",
41
+ "deepseek-v4-flash",
42
+ "deepseek-v3.2",
43
+ "deepseek-chat",
44
+ "deepseek-reasoner",
45
+ "deepseek-r1",
46
+ "deepseek-r1-0528"
47
+ ],
48
+ "xAI": [
49
+ "grok-4.3",
50
+ "grok-4.1",
51
+ "grok-4",
52
+ "grok-3"
53
+ ],
54
+ "Groq": [
55
+ "groq/compound",
56
+ "groq/compound-mini",
57
+ "llama-3.1-8b-instant",
58
+ "llama-3.1-70b-versatile",
59
+ "llama-3.3-70b-versatile",
60
+ "meta-llama/llama-4-scout-17b-16e-instruct",
61
+ "openai/gpt-oss-20b",
62
+ "openai/gpt-oss-120b",
63
+ "qwen/qwen3-32b",
64
+ "mixtral-8x7b-32768"
65
+ ],
66
+ "Mistral": [
67
+ "mistral-large-latest",
68
+ "mistral-large-2",
69
+ "mistral-medium-3.5",
70
+ "mistral-small-latest",
71
+ "mistral-small-3.2",
72
+ "devstral-2",
73
+ "ocr-3-premier",
74
+ "voxtral-mini-transcribe-realtime",
75
+ "codestral-latest"
76
+ ],
77
+ "Cohere": [
78
+ "command-a",
79
+ "command-a-03-2025",
80
+ "command-a-translate-08-2025",
81
+ "command-a-reasoning-08-2025",
82
+ "command-a-vision-07-2025",
83
+ "command-r7b-12-2024",
84
+ "command-r-08-2024",
85
+ "command-r-plus-08-2024"
86
+ ],
87
+ "OpenRouter": [
88
+ "openrouter/free",
89
+ "openrouter/auto",
90
+ "anthropic/claude-opus-4-7",
91
+ "anthropic/claude-sonnet-4-6",
92
+ "anthropic/claude-haiku-4-5",
93
+ "openai/gpt-5.4",
94
+ "openai/gpt-4.1",
95
+ "openai/gpt-4o",
96
+ "openai/gpt-5.1",
97
+ "google/gemini-3.1-pro-preview",
98
+ "google/gemini-2.5-pro",
99
+ "deepseek/deepseek-v3.2",
100
+ "deepseek/deepseek-r1",
101
+ "moonshotai/kimi-k2.5",
102
+ "qwen/qwen3-32b",
103
+ "meta-llama/llama-3.3-70b-instruct"
104
+ ],
105
+ "Together": [
106
+ "moonshotai/Kimi-K2.5",
107
+ "deepseek-ai/DeepSeek-R1",
108
+ "Qwen/Qwen3-235B-A22B-Instruct-2507-tput",
109
+ "zai-org/GLM-5.1",
110
+ "google/gemma-4-31B-it",
111
+ "MiniMaxAI/MiniMax-M2.7",
112
+ "meta-llama/Llama-3.3-70B-Instruct-Turbo",
113
+ "openai/gpt-oss-20b",
114
+ "openai/gpt-oss-120b",
115
+ "mistralai/Mistral-Small-3.2-24B-Instruct-2506",
116
+ "moonshotai/Kimi-K2.5-Instruct"
117
+ ],
118
+ "OpenCode": [
119
+ "opencode/claude-opus-4-6",
120
+ "opencode/gpt-5.4",
121
+ "opencode-go/kimi-k2.5",
122
+ "opencode-go/qwen3-32b"
123
+ ],
124
+ "Cerebras": [
125
+ "cerebras/zai-glm-4.7",
126
+ "cerebras/deepseek-r1",
127
+ "cerebras/llama-4-scout-17b-16e-instruct",
128
+ "cerebras/qwen3-32b"
129
+ ],
130
+ "NVIDIA": [
131
+ "nvidia/nemotron-3-super-120b-a12b",
132
+ "nvidia/nemotron-4-340b-instruct",
133
+ "nvidia/llama-3.1-nemotron-70b-instruct"
134
+ ],
135
+ "KiloCode": [
136
+ "kilocode/anthropic/claude-opus-4.6",
137
+ "kilocode/anthropic/claude-sonnet-4.6",
138
+ "kilocode/openai/gpt-5.4",
139
+ "kilocode/google/gemini-2.5-pro"
140
+ ],
141
+ "Z.AI": [
142
+ "zai-org/GLM-5.1",
143
+ "zai-org/GLM-4.7",
144
+ "zai-org/GLM-4.5"
145
+ ],
146
+ "Moonshot": [
147
+ "moonshot/kimi-k2.5",
148
+ "moonshot/kimi-k2.5-thinking",
149
+ "moonshot/kimi-k2.5-coder"
150
+ ],
151
+ "MiniMax": [
152
+ "minimax/minimax-m2.7",
153
+ "minimax/minimax-m1.5",
154
+ "minimax/abab6.5s-chat"
155
+ ],
156
+ "Xiaomi": [
157
+ "xiaomi/mimo-v1",
158
+ "xiaomi/mimo-v2",
159
+ "xiaomi/mi-mo"
160
+ ],
161
+ "Volcano Engine": [
162
+ "volcengine/doubao-seed-1.6",
163
+ "volcengine/doubao-1.5-pro",
164
+ "volcengine/doubao-1.5-lite"
165
+ ],
166
+ "BytePlus": [
167
+ "byteplus/seed-1.6",
168
+ "byteplus/deepseek-v3.2",
169
+ "byteplus/doubao-seed-1.6"
170
+ ],
171
+ "Qianfan": [
172
+ "qianfan/ernie-4.5",
173
+ "qianfan/ernie-4.5-8k",
174
+ "qianfan/deepseek-v3.2",
175
+ "qianfan/ernie-x1"
176
+ ],
177
+ "ModelStudio": [
178
+ "modelstudio/qwen3-max",
179
+ "modelstudio/qwen3-coder",
180
+ "modelstudio/qwen3-32b"
181
+ ],
182
+ "Hugging Face": [
183
+ "meta-llama/Llama-3.3-70B-Instruct",
184
+ "Qwen/Qwen3-32B",
185
+ "google/gemma-4-31B-it",
186
+ "deepseek-ai/DeepSeek-V3.2",
187
+ "moonshotai/Kimi-K2.5"
188
+ ],
189
+ "Venice": [
190
+ "venice/gpt-5",
191
+ "venice/llama-3.3-70b",
192
+ "venice/deepseek-r1"
193
+ ],
194
+ "Synthetic": [
195
+ "synthetic/gpt-5",
196
+ "synthetic/claude-sonnet-4-6"
197
+ ],
198
+ "AI Gateway": [
199
+ "openai/gpt-5.4",
200
+ "anthropic/claude-sonnet-4-6",
201
+ "google/gemini-2.5-pro"
202
+ ],
203
+ "GitHub Copilot": [
204
+ "github-copilot/gpt-5",
205
+ "github-copilot/gpt-4.1",
206
+ "github-copilot/gpt-4o"
207
+ ],
208
+ "ZAI": [
209
+ "zai/glm-5",
210
+ "zai/glm-5-turbo",
211
+ "zai/glm-4.7",
212
+ "zai/glm-4.7-flash"
213
+ ],
214
+ "Kimi": [
215
+ "moonshot/kimi-k2.5",
216
+ "moonshot/kimi-k2.5-thinking"
217
+ ],
218
+ "HuggingFace": [
219
+ "huggingface/deepseek-ai/DeepSeek-R1",
220
+ "huggingface/meta-llama/Llama-3.3-70B-Instruct",
221
+ "huggingface/Qwen/Qwen3-32B"
222
+ ]
223
+ },
224
+ "OPENAI_MODELS": [
225
+ "gpt-5.4-pro",
226
+ "gpt-5.4",
227
+ "gpt-5.4-mini",
228
+ "gpt-5.4-nano",
229
+ "gpt-5.1",
230
+ "gpt-5",
231
+ "gpt-4.1",
232
+ "gpt-4.1-mini",
233
+ "gpt-4o",
234
+ "gpt-4o-mini",
235
+ "o3",
236
+ "o4-mini"
237
+ ],
238
+ "ANTHROPIC_MODELS": [
239
+ "anthropic/claude-opus-4-7",
240
+ "anthropic/claude-opus-4-6",
241
+ "anthropic/claude-opus-4-5",
242
+ "anthropic/claude-sonnet-4-6",
243
+ "anthropic/claude-sonnet-4-5",
244
+ "anthropic/claude-haiku-4-5"
245
+ ],
246
+ "GEMINI_MODELS": [
247
+ "google/gemini-3.1-pro-preview",
248
+ "google/gemini-3.1-flash-preview",
249
+ "google/gemini-3-flash-preview",
250
+ "google/gemini-2.5-pro",
251
+ "google/gemini-2.5-flash",
252
+ "google/gemini-2.0-flash"
253
+ ],
254
+ "DEEPSEEK_MODELS": [
255
+ "deepseek/deepseek-v4-pro",
256
+ "deepseek/deepseek-v4-flash",
257
+ "deepseek/deepseek-v3.2",
258
+ "deepseek/deepseek-chat",
259
+ "deepseek/deepseek-reasoner",
260
+ "deepseek/deepseek-r1",
261
+ "deepseek/deepseek-r1-0528"
262
+ ],
263
+ "OPENROUTER_MODELS": [
264
+ "openrouter/free",
265
+ "openrouter/auto",
266
+ "openrouter/anthropic/claude-sonnet-4-6",
267
+ "openrouter/anthropic/claude-opus-4-7",
268
+ "openrouter/anthropic/claude-haiku-4-5",
269
+ "openrouter/openai/gpt-5.4",
270
+ "openrouter/openai/gpt-4.1",
271
+ "openrouter/openai/gpt-4o",
272
+ "openrouter/openai/gpt-5.1",
273
+ "openrouter/google/gemini-3.1-pro-preview",
274
+ "openrouter/google/gemini-2.5-pro",
275
+ "openrouter/deepseek/deepseek-v3.2",
276
+ "openrouter/deepseek/deepseek-r1",
277
+ "openrouter/moonshotai/kimi-k2.5",
278
+ "openrouter/qwen/qwen3-32b"
279
+ ],
280
+ "GROQ_MODELS": [
281
+ "groq/compound",
282
+ "groq/compound-mini",
283
+ "llama-3.1-8b-instant",
284
+ "llama-3.1-70b-versatile",
285
+ "llama-3.3-70b-versatile",
286
+ "openai/gpt-oss-20b",
287
+ "openai/gpt-oss-120b",
288
+ "meta-llama/llama-4-scout-17b-16e-instruct",
289
+ "qwen/qwen3-32b",
290
+ "mixtral-8x7b-32768"
291
+ ],
292
+ "MISTRAL_MODELS": [
293
+ "mistral-large-latest",
294
+ "mistral-large-2",
295
+ "mistral-medium-3.5",
296
+ "mistral-small-latest",
297
+ "mistral-small-3.2",
298
+ "devstral-2",
299
+ "ocr-3-premier",
300
+ "voxtral-mini-transcribe-realtime",
301
+ "codestral-latest"
302
+ ],
303
+ "XAI_MODELS": [
304
+ "grok-4.3",
305
+ "grok-4.1",
306
+ "grok-4",
307
+ "grok-3"
308
+ ],
309
+ "COHERE_MODELS": [
310
+ "command-a",
311
+ "command-a-03-2025",
312
+ "command-a-translate-08-2025",
313
+ "command-a-reasoning-08-2025",
314
+ "command-a-vision-07-2025",
315
+ "command-r7b-12-2024",
316
+ "command-r-08-2024",
317
+ "command-r-plus-08-2024"
318
+ ],
319
+ "TOGETHER_MODELS": [
320
+ "moonshotai/Kimi-K2.5",
321
+ "deepseek-ai/DeepSeek-R1",
322
+ "Qwen/Qwen3-235B-A22B-Instruct-2507-tput",
323
+ "zai-org/GLM-5.1",
324
+ "google/gemma-4-31B-it",
325
+ "MiniMaxAI/MiniMax-M2.7",
326
+ "meta-llama/Llama-3.3-70B-Instruct-Turbo",
327
+ "openai/gpt-oss-20b",
328
+ "openai/gpt-oss-120b",
329
+ "mistralai/Mistral-Small-3.2-24B-Instruct-2506",
330
+ "moonshotai/Kimi-K2.5-Instruct"
331
+ ],
332
+ "CEREBRAS_MODELS": [
333
+ "cerebras/zai-glm-4.7",
334
+ "cerebras/deepseek-r1",
335
+ "cerebras/llama-4-scout-17b-16e-instruct",
336
+ "cerebras/qwen3-32b"
337
+ ],
338
+ "NVIDIA_MODELS": [
339
+ "nvidia/nemotron-3-super-120b-a12b",
340
+ "nvidia/nemotron-4-340b-instruct",
341
+ "nvidia/llama-3.1-nemotron-70b-instruct"
342
+ ],
343
+ "KILOCODE_MODELS": [
344
+ "kilocode/anthropic/claude-opus-4.6",
345
+ "kilocode/anthropic/claude-sonnet-4.6",
346
+ "kilocode/openai/gpt-5.4",
347
+ "kilocode/google/gemini-2.5-pro"
348
+ ],
349
+ "OPENCODE_MODELS": [
350
+ "opencode/claude-opus-4-6",
351
+ "opencode/gpt-5.4",
352
+ "opencode-go/kimi-k2.5",
353
+ "opencode-go/qwen3-32b"
354
+ ],
355
+ "ZAI_MODELS": [
356
+ "zai/glm-5",
357
+ "zai/glm-5-turbo",
358
+ "zai/glm-4.7",
359
+ "zai/glm-4.7-flash"
360
+ ],
361
+ "MOONSHOT_MODELS": [
362
+ "moonshot/kimi-k2.5",
363
+ "moonshot/kimi-k2.5-thinking",
364
+ "moonshot/kimi-k2.5-coder"
365
+ ],
366
+ "MINIMAX_MODELS": [
367
+ "minimax/minimax-m2.7",
368
+ "minimax/minimax-m1.5",
369
+ "minimax/abab6.5s-chat"
370
+ ],
371
+ "XIAOMI_MODELS": [
372
+ "xiaomi/mimo-v1",
373
+ "xiaomi/mimo-v2",
374
+ "xiaomi/mi-mo"
375
+ ],
376
+ "VOLCANO_ENGINE_MODELS": [
377
+ "volcengine/doubao-seed-1.6",
378
+ "volcengine/doubao-1.5-pro",
379
+ "volcengine/doubao-1.5-lite"
380
+ ],
381
+ "BYTEPLUS_MODELS": [
382
+ "byteplus/seed-1.6",
383
+ "byteplus/deepseek-v3.2",
384
+ "byteplus/doubao-seed-1.6"
385
+ ],
386
+ "QIANFAN_MODELS": [
387
+ "qianfan/ernie-4.5",
388
+ "qianfan/ernie-4.5-8k",
389
+ "qianfan/deepseek-v3.2",
390
+ "qianfan/ernie-x1"
391
+ ],
392
+ "MODELSTUDIO_MODELS": [
393
+ "modelstudio/qwen3-max",
394
+ "modelstudio/qwen3-coder",
395
+ "modelstudio/qwen3-32b"
396
+ ],
397
+ "KIMI_MODELS": [
398
+ "moonshot/kimi-k2.5",
399
+ "moonshot/kimi-k2.5-thinking",
400
+ "moonshot/kimi-k2.5-coder"
401
+ ],
402
+ "HUGGINGFACE_MODELS": [
403
+ "huggingface/deepseek-ai/DeepSeek-R1",
404
+ "huggingface/meta-llama/Llama-3.3-70B-Instruct",
405
+ "huggingface/Qwen/Qwen3-32B",
406
+ "huggingface/mistralai/Mistral-Small-3.2-24B-Instruct-2506"
407
+ ],
408
+ "GITHUB_COPILOT_MODELS": [
409
+ "github-copilot/gpt-5",
410
+ "github-copilot/gpt-4.1",
411
+ "github-copilot/gpt-4o"
412
+ ],
413
+ "AI_GATEWAY_MODELS": [],
414
+ "VENICE_MODELS": [],
415
+ "SYNTHETIC_MODELS": []
416
+ };
417
+
418
+ const FIELDS = [
419
+ {
420
+ "g": "Core",
421
+ "icon": "⚡",
422
+ "k": "LLM_MODEL",
423
+ "lbl": "Default model ID",
424
+ "type": "model",
425
+ "options_key": "LLM_MODEL",
426
+ "ph": "choose a provider model",
427
+ "common": 1
428
+ },
429
+ {
430
+ "g": "Core",
431
+ "icon": "⚡",
432
+ "k": "LLM_API_KEY",
433
+ "lbl": "Primary provider API key",
434
+ "type": "password",
435
+ "secret": 1,
436
+ "ph": "sk-...",
437
+ "common": 1
438
+ },
439
+ {
440
+ "g": "Core",
441
+ "icon": "⚡",
442
+ "k": "GATEWAY_TOKEN",
443
+ "lbl": "Control UI gateway token",
444
+ "type": "password",
445
+ "secret": 1,
446
+ "common": 1
447
+ },
448
+ {
449
+ "g": "Core",
450
+ "icon": "⚡",
451
+ "k": "OPENCLAW_PASSWORD",
452
+ "lbl": "Optional password auth",
453
+ "type": "password",
454
+ "secret": 1
455
+ },
456
+ {
457
+ "g": "Core",
458
+ "icon": "⚡",
459
+ "k": "OPENCLAW_VERSION",
460
+ "lbl": "Pin OpenClaw version",
461
+ "type": "text",
462
+ "ph": "latest"
463
+ },
464
+ {
465
+ "g": "Core",
466
+ "icon": "⚡",
467
+ "k": "LLM_API_KEY_FALLBACK_ENABLED",
468
+ "lbl": "Allow global LLM_API_KEY fallback",
469
+ "type": "toggle",
470
+ "ph": "true"
471
+ },
472
+ {
473
+ "g": "Core",
474
+ "icon": "⚡",
475
+ "k": "DEV_MODE",
476
+ "lbl": "Enable dev mode",
477
+ "type": "toggle",
478
+ "ph": "false",
479
+ "common": 1
480
+ },
481
+ {
482
+ "g": "Core",
483
+ "icon": "⚡",
484
+ "k": "HUGGINGCLAW_JUPYTER_ENABLED",
485
+ "lbl": "Enable Jupyter terminal",
486
+ "type": "toggle",
487
+ "ph": "false",
488
+ "common": 1
489
+ },
490
+ {
491
+ "g": "Core",
492
+ "icon": "⚡",
493
+ "k": "DEVDATA",
494
+ "lbl": "DevData switch",
495
+ "type": "toggle",
496
+ "ph": "on",
497
+ "common": 1
498
+ },
499
+ {
500
+ "g": "Core",
501
+ "icon": "⚡",
502
+ "k": "DEVDATA_DATASET_NAME",
503
+ "lbl": "DevData dataset name",
504
+ "type": "text",
505
+ "ph": "huggingclaw-devdata",
506
+ "common": 1
507
+ },
508
+ {
509
+ "g": "Core",
510
+ "icon": "⚡",
511
+ "k": "DEVDATA_SYNC_INTERVAL",
512
+ "lbl": "DevData sync interval (seconds)",
513
+ "type": "number",
514
+ "ph": "180"
515
+ },
516
+ {
517
+ "g": "Core",
518
+ "icon": "⚡",
519
+ "k": "WHATSAPP_ENABLED",
520
+ "lbl": "Enable WhatsApp pairing",
521
+ "type": "toggle",
522
+ "ph": "false",
523
+ "common": 1
524
+ },
525
+ {
526
+ "g": "Core",
527
+ "icon": "⚡",
528
+ "k": "HUGGINGCLAW_CAPTURE_DISABLE",
529
+ "lbl": "Disable capture wrapper",
530
+ "type": "toggle",
531
+ "ph": "false"
532
+ },
533
+ {
534
+ "g": "Core",
535
+ "icon": "⚡",
536
+ "k": "HUGGINGCLAW_STARTUP_STRICT",
537
+ "lbl": "Stop on startup failure",
538
+ "type": "toggle",
539
+ "ph": "false"
540
+ },
541
+ {
542
+ "g": "Core",
543
+ "icon": "⚡",
544
+ "k": "HUGGINGCLAW_RUN",
545
+ "lbl": "Startup command (one-liner)",
546
+ "type": "textarea"
547
+ },
548
+ {
549
+ "g": "Core",
550
+ "icon": "⚡",
551
+ "k": "HUGGINGCLAW_STARTUP_COMMANDS",
552
+ "lbl": "Multiline startup commands",
553
+ "type": "textarea"
554
+ },
555
+ {
556
+ "g": "Core",
557
+ "icon": "⚡",
558
+ "k": "HUGGINGCLAW_STARTUP_SCRIPT",
559
+ "lbl": "Startup shell script",
560
+ "type": "textarea"
561
+ },
562
+ {
563
+ "g": "Core",
564
+ "icon": "⚡",
565
+ "k": "HUGGINGCLAW_STARTUP_SCRIPT_B64",
566
+ "lbl": "Startup script (base64)",
567
+ "type": "textarea"
568
+ },
569
+ {
570
+ "g": "Core",
571
+ "icon": "⚡",
572
+ "k": "HUGGINGCLAW_APT_PACKAGES",
573
+ "lbl": "APT packages to install",
574
+ "type": "textarea"
575
+ },
576
+ {
577
+ "g": "Core",
578
+ "icon": "⚡",
579
+ "k": "HUGGINGCLAW_PIP_PACKAGES",
580
+ "lbl": "Pip packages to install",
581
+ "type": "textarea"
582
+ },
583
+ {
584
+ "g": "Core",
585
+ "icon": "⚡",
586
+ "k": "HUGGINGCLAW_NPM_PACKAGES",
587
+ "lbl": "NPM packages to install",
588
+ "type": "textarea"
589
+ },
590
+ {
591
+ "g": "Core",
592
+ "icon": "⚡",
593
+ "k": "HUGGINGCLAW_OPENCLAW_PLUGINS",
594
+ "lbl": "OpenClaw plugins to load",
595
+ "type": "textarea"
596
+ },
597
+ {
598
+ "g": "Core",
599
+ "icon": "⚡",
600
+ "k": "ALLOWED_ORIGINS",
601
+ "lbl": "Allowed CORS origins",
602
+ "type": "textarea"
603
+ },
604
+ {
605
+ "g": "Core",
606
+ "icon": "⚡",
607
+ "k": "TRUSTED_PROXIES",
608
+ "lbl": "Trusted proxy CIDRs",
609
+ "type": "textarea"
610
+ },
611
+ {
612
+ "g": "Core",
613
+ "icon": "⚡",
614
+ "k": "WEBHOOK_URL",
615
+ "lbl": "Webhook URL",
616
+ "type": "text",
617
+ "ph": "https://..."
618
+ },
619
+ {
620
+ "g": "Core",
621
+ "icon": "⚡",
622
+ "k": "WS_MIN_PROTOCOL",
623
+ "lbl": "Min WebSocket protocol",
624
+ "type": "number",
625
+ "ph": "1"
626
+ },
627
+ {
628
+ "g": "Core",
629
+ "icon": "⚡",
630
+ "k": "WS_MAX_PROTOCOL",
631
+ "lbl": "Max WebSocket protocol",
632
+ "type": "number",
633
+ "ph": "5"
634
+ },
635
+ {
636
+ "g": "Core",
637
+ "icon": "⚡",
638
+ "k": "GATEWAY_MAX_RESTARTS",
639
+ "lbl": "Gateway max restarts",
640
+ "type": "number",
641
+ "ph": "10"
642
+ },
643
+ {
644
+ "g": "Core",
645
+ "icon": "⚡",
646
+ "k": "GATEWAY_READY_TIMEOUT",
647
+ "lbl": "Gateway ready timeout",
648
+ "type": "number",
649
+ "ph": "90"
650
+ },
651
+ {
652
+ "g": "Core",
653
+ "icon": "⚡",
654
+ "k": "GATEWAY_RESTART_DELAY",
655
+ "lbl": "Gateway restart delay",
656
+ "type": "number",
657
+ "ph": "5"
658
+ },
659
+ {
660
+ "g": "Core",
661
+ "icon": "⚡",
662
+ "k": "GATEWAY_VERBOSE",
663
+ "lbl": "Verbose gateway logs",
664
+ "type": "toggle",
665
+ "ph": "false"
666
+ },
667
+ {
668
+ "g": "Core",
669
+ "icon": "⚡",
670
+ "k": "OPENCLAW_CONSOLE_LOG_LEVEL",
671
+ "lbl": "Console log level",
672
+ "type": "select",
673
+ "options": [
674
+ "debug",
675
+ "info",
676
+ "warn",
677
+ "error"
678
+ ],
679
+ "ph": "info"
680
+ },
681
+ {
682
+ "g": "Core",
683
+ "icon": "⚡",
684
+ "k": "OPENCLAW_FILE_LOG_LEVEL",
685
+ "lbl": "File log level",
686
+ "type": "select",
687
+ "options": [
688
+ "debug",
689
+ "info",
690
+ "warn",
691
+ "error"
692
+ ],
693
+ "ph": "info"
694
+ },
695
+ {
696
+ "g": "Core",
697
+ "icon": "⚡",
698
+ "k": "OPENCLAW_CONSOLE_LOG_STYLE",
699
+ "lbl": "Console log style",
700
+ "type": "select",
701
+ "options": [
702
+ "pretty",
703
+ "json",
704
+ "compact"
705
+ ],
706
+ "ph": "pretty"
707
+ },
708
+ {
709
+ "g": "Core",
710
+ "icon": "⚡",
711
+ "k": "BROWSER_PLUGIN_MODE",
712
+ "lbl": "Browser plugin mode",
713
+ "type": "select",
714
+ "options": [
715
+ "auto",
716
+ "enabled",
717
+ "disabled"
718
+ ],
719
+ "ph": "auto"
720
+ },
721
+ {
722
+ "g": "Core",
723
+ "icon": "⚡",
724
+ "k": "ACP_PLUGIN_MODE",
725
+ "lbl": "ACP plugin mode",
726
+ "type": "select",
727
+ "options": [
728
+ "auto",
729
+ "enabled",
730
+ "disabled"
731
+ ],
732
+ "ph": "auto"
733
+ },
734
+ {
735
+ "g": "Core",
736
+ "icon": "⚡",
737
+ "k": "CLOUDFLARE_PROXY_DEBUG",
738
+ "lbl": "Cloudflare proxy debug",
739
+ "type": "toggle",
740
+ "ph": "false"
741
+ },
742
+ {
743
+ "g": "Core",
744
+ "icon": "⚡",
745
+ "k": "CLOUDFLARE_KEEPALIVE_ENABLED",
746
+ "lbl": "Enable keep-awake worker",
747
+ "type": "toggle",
748
+ "ph": "true"
749
+ },
750
+ {
751
+ "g": "Core",
752
+ "icon": "⚡",
753
+ "k": "CLOUDFLARE_PROXY_URL",
754
+ "lbl": "Proxy worker URL",
755
+ "type": "text",
756
+ "ph": "https://your-proxy.workers.dev",
757
+ "common": 1
758
+ },
759
+ {
760
+ "g": "Core",
761
+ "icon": "⚡",
762
+ "k": "CLOUDFLARE_PROXY_SECRET",
763
+ "lbl": "Proxy shared secret",
764
+ "type": "password",
765
+ "secret": 1
766
+ },
767
+ {
768
+ "g": "Core",
769
+ "icon": "⚡",
770
+ "k": "CLOUDFLARE_PROXY_DOMAINS",
771
+ "lbl": "Extra domains to proxy",
772
+ "type": "textarea",
773
+ "ph": "api.sendgrid.com,slack.com"
774
+ },
775
+ {
776
+ "g": "Core",
777
+ "icon": "⚡",
778
+ "k": "CLOUDFLARE_WORKERS_TOKEN",
779
+ "lbl": "Workers API token",
780
+ "type": "password",
781
+ "secret": 1,
782
+ "common": 1
783
+ },
784
+ {
785
+ "g": "Core",
786
+ "icon": "⚡",
787
+ "k": "HF_USERNAME",
788
+ "lbl": "Hugging Face username",
789
+ "type": "text",
790
+ "common": 1
791
+ },
792
+ {
793
+ "g": "Core",
794
+ "icon": "⚡",
795
+ "k": "HF_TOKEN",
796
+ "lbl": "HF write token",
797
+ "type": "password",
798
+ "secret": 1,
799
+ "common": 1
800
+ },
801
+ {
802
+ "g": "Core",
803
+ "icon": "⚡",
804
+ "k": "BACKUP_DATASET_NAME",
805
+ "lbl": "Backup dataset name",
806
+ "type": "text",
807
+ "ph": "huggingclaw-backup",
808
+ "common": 1
809
+ },
810
+ {
811
+ "g": "Core",
812
+ "icon": "⚡",
813
+ "k": "SYNC_INTERVAL",
814
+ "lbl": "Sync interval (seconds)",
815
+ "type": "number",
816
+ "ph": "180",
817
+ "common": 1
818
+ },
819
+ {
820
+ "g": "Core",
821
+ "icon": "⚡",
822
+ "k": "JUPYTER_TOKEN",
823
+ "lbl": "Jupyter access token",
824
+ "type": "password",
825
+ "secret": 1,
826
+ "ph": "huggingface",
827
+ "common": 1
828
+ },
829
+ {
830
+ "g": "Core",
831
+ "icon": "⚡",
832
+ "k": "KEEP_ALIVE_INTERVAL",
833
+ "lbl": "Keep-alive ping interval (seconds)",
834
+ "type": "number",
835
+ "ph": "300",
836
+ "common": 1
837
+ },
838
+ {
839
+ "g": "Core",
840
+ "icon": "⚡",
841
+ "k": "OPENCLAW_DISABLE_BONJOUR",
842
+ "lbl": "Disable Bonjour/mDNS discovery",
843
+ "type": "toggle",
844
+ "ph": "false"
845
+ },
846
+ {
847
+ "g": "Core",
848
+ "icon": "⚡",
849
+ "k": "OPENCLAW_RUNTIME_VERSION",
850
+ "lbl": "Pin runtime version",
851
+ "type": "text",
852
+ "ph": "latest"
853
+ },
854
+ {
855
+ "g": "Core",
856
+ "icon": "⚡",
857
+ "k": "OPENCLAW_DISPLAY_VERSION",
858
+ "lbl": "Display version label",
859
+ "type": "text",
860
+ "ph": ""
861
+ },
862
+ {
863
+ "g": "Integrations",
864
+ "icon": "🔌",
865
+ "k": "CLOUDFLARE_ACCOUNT_ID",
866
+ "lbl": "Cloudflare account ID",
867
+ "type": "text",
868
+ "ph": "account-id"
869
+ },
870
+ {
871
+ "g": "Integrations",
872
+ "icon": "🔌",
873
+ "k": "CLOUDFLARE_WORKER_NAME",
874
+ "lbl": "Outbound proxy worker name",
875
+ "type": "text",
876
+ "ph": "huggingclaw-proxy"
877
+ },
878
+ {
879
+ "g": "Integrations",
880
+ "icon": "🔌",
881
+ "k": "CLOUDFLARE_KEEPALIVE_URL",
882
+ "lbl": "Keepalive worker URL",
883
+ "type": "text",
884
+ "ph": "https://your-worker.workers.dev"
885
+ },
886
+ {
887
+ "g": "Integrations",
888
+ "icon": "🔌",
889
+ "k": "CLOUDFLARE_KEEPALIVE_WORKER_NAME",
890
+ "lbl": "Keepalive worker name",
891
+ "type": "text",
892
+ "ph": "huggingclaw-keepalive"
893
+ },
894
+ {
895
+ "g": "Integrations",
896
+ "icon": "🔌",
897
+ "k": "CLOUDFLARE_KEEPALIVE_CRON",
898
+ "lbl": "Keepalive cron schedule",
899
+ "type": "text",
900
+ "ph": "*/5 * * * *"
901
+ },
902
+ {
903
+ "g": "Integrations",
904
+ "icon": "🔌",
905
+ "k": "TELEGRAM_API_ROOT",
906
+ "lbl": "Telegram API root override",
907
+ "type": "text",
908
+ "ph": "https://api.telegram.org"
909
+ },
910
+ {
911
+ "g": "Runtime",
912
+ "icon": "⚙️",
913
+ "k": "OPENCLAW_CONFIG_WATCH_INTERVAL",
914
+ "lbl": "Config watch interval (seconds)",
915
+ "type": "number",
916
+ "ph": "1"
917
+ },
918
+ {
919
+ "g": "Runtime",
920
+ "icon": "⚙️",
921
+ "k": "OPENCLAW_CONFIG_SETTLE_SECONDS",
922
+ "lbl": "Config settle window (seconds)",
923
+ "type": "number",
924
+ "ph": "3"
925
+ },
926
+ {
927
+ "g": "Runtime",
928
+ "icon": "⚙️",
929
+ "k": "JUPYTER_ROOT_DIR",
930
+ "lbl": "Jupyter root directory",
931
+ "type": "text",
932
+ "ph": "/home/node"
933
+ },
934
+ {
935
+ "g": "Backup",
936
+ "icon": "💾",
937
+ "k": "WORKSPACE_GIT_USER",
938
+ "lbl": "Workspace git author email",
939
+ "type": "text",
940
+ "ph": "openclaw@example.com"
941
+ },
942
+ {
943
+ "g": "Backup",
944
+ "icon": "💾",
945
+ "k": "WORKSPACE_GIT_NAME",
946
+ "lbl": "Workspace git author name",
947
+ "type": "text",
948
+ "ph": "OpenClaw Bot"
949
+ },
950
+ {
951
+ "g": "Provider Keys",
952
+ "icon": "🔑",
953
+ "k": "ANTHROPIC_API_KEY",
954
+ "lbl": "Anthropic (Claude)",
955
+ "type": "password",
956
+ "secret": 1,
957
+ "common": 0
958
+ },
959
+ {
960
+ "g": "Provider Keys",
961
+ "icon": "🔑",
962
+ "k": "OPENAI_API_KEY",
963
+ "lbl": "OpenAI (GPT)",
964
+ "type": "password",
965
+ "secret": 1,
966
+ "common": 0
967
+ },
968
+ {
969
+ "g": "Provider Keys",
970
+ "icon": "🔑",
971
+ "k": "GOOGLE_API_KEY",
972
+ "lbl": "Google AI Studio",
973
+ "type": "password",
974
+ "secret": 1,
975
+ "common": 0
976
+ },
977
+ {
978
+ "g": "Provider Keys",
979
+ "icon": "🔑",
980
+ "k": "GEMINI_API_KEY",
981
+ "lbl": "Google Gemini",
982
+ "type": "password",
983
+ "secret": 1,
984
+ "common": 0
985
+ },
986
+ {
987
+ "g": "Provider Keys",
988
+ "icon": "🔑",
989
+ "k": "DEEPSEEK_API_KEY",
990
+ "lbl": "DeepSeek",
991
+ "type": "password",
992
+ "secret": 1,
993
+ "common": 0
994
+ },
995
+ {
996
+ "g": "Provider Keys",
997
+ "icon": "🔑",
998
+ "k": "OPENROUTER_API_KEY",
999
+ "lbl": "OpenRouter",
1000
+ "type": "password",
1001
+ "secret": 1,
1002
+ "common": 1
1003
+ },
1004
+ {
1005
+ "g": "Provider Keys",
1006
+ "icon": "🔑",
1007
+ "k": "OPENCODE_API_KEY",
1008
+ "lbl": "OpenCode",
1009
+ "type": "password",
1010
+ "secret": 1,
1011
+ "common": 0
1012
+ },
1013
+ {
1014
+ "g": "Provider Keys",
1015
+ "icon": "🔑",
1016
+ "k": "KILOCODE_API_KEY",
1017
+ "lbl": "KiloCode",
1018
+ "type": "password",
1019
+ "secret": 1,
1020
+ "common": 0
1021
+ },
1022
+ {
1023
+ "g": "Provider Keys",
1024
+ "icon": "🔑",
1025
+ "k": "ZAI_API_KEY",
1026
+ "lbl": "Z.ai / GLM",
1027
+ "type": "password",
1028
+ "secret": 1,
1029
+ "common": 0
1030
+ },
1031
+ {
1032
+ "g": "Provider Keys",
1033
+ "icon": "🔑",
1034
+ "k": "MOONSHOT_API_KEY",
1035
+ "lbl": "Moonshot / Kimi",
1036
+ "type": "password",
1037
+ "secret": 1,
1038
+ "common": 0
1039
+ },
1040
+ {
1041
+ "g": "Provider Keys",
1042
+ "icon": "🔑",
1043
+ "k": "MINIMAX_API_KEY",
1044
+ "lbl": "MiniMax",
1045
+ "type": "password",
1046
+ "secret": 1,
1047
+ "common": 0
1048
+ },
1049
+ {
1050
+ "g": "Provider Keys",
1051
+ "icon": "🔑",
1052
+ "k": "XIAOMI_API_KEY",
1053
+ "lbl": "Xiaomi / MiMo",
1054
+ "type": "password",
1055
+ "secret": 1,
1056
+ "common": 0
1057
+ },
1058
+ {
1059
+ "g": "Provider Keys",
1060
+ "icon": "🔑",
1061
+ "k": "VOLCANO_ENGINE_API_KEY",
1062
+ "lbl": "Volcengine / Doubao",
1063
+ "type": "password",
1064
+ "secret": 1,
1065
+ "common": 0
1066
+ },
1067
+ {
1068
+ "g": "Provider Keys",
1069
+ "icon": "🔑",
1070
+ "k": "BYTEPLUS_API_KEY",
1071
+ "lbl": "BytePlus",
1072
+ "type": "password",
1073
+ "secret": 1,
1074
+ "common": 0
1075
+ },
1076
+ {
1077
+ "g": "Provider Keys",
1078
+ "icon": "🔑",
1079
+ "k": "MISTRAL_API_KEY",
1080
+ "lbl": "Mistral",
1081
+ "type": "password",
1082
+ "secret": 1,
1083
+ "common": 0
1084
+ },
1085
+ {
1086
+ "g": "Provider Keys",
1087
+ "icon": "🔑",
1088
+ "k": "XAI_API_KEY",
1089
+ "lbl": "xAI (Grok)",
1090
+ "type": "password",
1091
+ "secret": 1,
1092
+ "common": 0
1093
+ },
1094
+ {
1095
+ "g": "Provider Keys",
1096
+ "icon": "🔑",
1097
+ "k": "NVIDIA_API_KEY",
1098
+ "lbl": "NVIDIA",
1099
+ "type": "password",
1100
+ "secret": 1,
1101
+ "common": 0
1102
+ },
1103
+ {
1104
+ "g": "Provider Keys",
1105
+ "icon": "🔑",
1106
+ "k": "GROQ_API_KEY",
1107
+ "lbl": "Groq",
1108
+ "type": "password",
1109
+ "secret": 1,
1110
+ "common": 0
1111
+ },
1112
+ {
1113
+ "g": "Provider Keys",
1114
+ "icon": "🔑",
1115
+ "k": "COHERE_API_KEY",
1116
+ "lbl": "Cohere",
1117
+ "type": "password",
1118
+ "secret": 1,
1119
+ "common": 0
1120
+ },
1121
+ {
1122
+ "g": "Provider Keys",
1123
+ "icon": "🔑",
1124
+ "k": "TOGETHER_API_KEY",
1125
+ "lbl": "Together AI",
1126
+ "type": "password",
1127
+ "secret": 1,
1128
+ "common": 0
1129
+ },
1130
+ {
1131
+ "g": "Provider Keys",
1132
+ "icon": "🔑",
1133
+ "k": "CEREBRAS_API_KEY",
1134
+ "lbl": "Cerebras",
1135
+ "type": "password",
1136
+ "secret": 1,
1137
+ "common": 0
1138
+ },
1139
+ {
1140
+ "g": "Provider Keys",
1141
+ "icon": "🔑",
1142
+ "k": "QIANFAN_API_KEY",
1143
+ "lbl": "Qianfan",
1144
+ "type": "password",
1145
+ "secret": 1,
1146
+ "common": 0
1147
+ },
1148
+ {
1149
+ "g": "Provider Keys",
1150
+ "icon": "🔑",
1151
+ "k": "MODELSTUDIO_API_KEY",
1152
+ "lbl": "ModelStudio",
1153
+ "type": "password",
1154
+ "secret": 1,
1155
+ "common": 0
1156
+ },
1157
+ {
1158
+ "g": "Provider Keys",
1159
+ "icon": "🔑",
1160
+ "k": "KIMI_API_KEY",
1161
+ "lbl": "Kimi",
1162
+ "type": "password",
1163
+ "secret": 1,
1164
+ "common": 0
1165
+ },
1166
+ {
1167
+ "g": "Provider Keys",
1168
+ "icon": "🔑",
1169
+ "k": "HUGGINGFACE_HUB_TOKEN",
1170
+ "lbl": "Hugging Face token",
1171
+ "type": "password",
1172
+ "secret": 1,
1173
+ "common": 0
1174
+ },
1175
+ {
1176
+ "g": "Provider Keys",
1177
+ "icon": "🔑",
1178
+ "k": "COPILOT_GITHUB_TOKEN",
1179
+ "lbl": "GitHub Copilot",
1180
+ "type": "password",
1181
+ "secret": 1,
1182
+ "common": 0
1183
+ },
1184
+ {
1185
+ "g": "Provider Keys",
1186
+ "icon": "🔑",
1187
+ "k": "VENICE_API_KEY",
1188
+ "lbl": "Venice",
1189
+ "type": "password",
1190
+ "secret": 1,
1191
+ "common": 0
1192
+ },
1193
+ {
1194
+ "g": "Provider Keys",
1195
+ "icon": "🔑",
1196
+ "k": "SYNTHETIC_API_KEY",
1197
+ "lbl": "Synthetic",
1198
+ "type": "password",
1199
+ "secret": 1,
1200
+ "common": 0
1201
+ },
1202
+ {
1203
+ "g": "Provider Keys",
1204
+ "icon": "🔑",
1205
+ "k": "AI_GATEWAY_API_KEY",
1206
+ "lbl": "AI Gateway",
1207
+ "type": "password",
1208
+ "secret": 1,
1209
+ "common": 0
1210
+ },
1211
+ {
1212
+ "g": "Provider Keys",
1213
+ "icon": "🔑",
1214
+ "k": "CLOUDFLARE_API_TOKEN",
1215
+ "lbl": "Cloudflare API token",
1216
+ "type": "password",
1217
+ "secret": 1,
1218
+ "common": 0
1219
+ },
1220
+ {
1221
+ "g": "Rotation Pools",
1222
+ "icon": "🔄",
1223
+ "k": "ANTHROPIC_API_KEYS",
1224
+ "lbl": "Anthropic pool (comma-sep)",
1225
+ "type": "text"
1226
+ },
1227
+ {
1228
+ "g": "Rotation Pools",
1229
+ "icon": "🔄",
1230
+ "k": "OPENAI_API_KEYS",
1231
+ "lbl": "OpenAI pool",
1232
+ "type": "text"
1233
+ },
1234
+ {
1235
+ "g": "Rotation Pools",
1236
+ "icon": "🔄",
1237
+ "k": "GEMINI_API_KEYS",
1238
+ "lbl": "Gemini pool",
1239
+ "type": "text"
1240
+ },
1241
+ {
1242
+ "g": "Rotation Pools",
1243
+ "icon": "🔄",
1244
+ "k": "GOOGLE_API_KEYS",
1245
+ "lbl": "Google pool",
1246
+ "type": "text"
1247
+ },
1248
+ {
1249
+ "g": "Rotation Pools",
1250
+ "icon": "🔄",
1251
+ "k": "DEEPSEEK_API_KEYS",
1252
+ "lbl": "DeepSeek pool",
1253
+ "type": "text"
1254
+ },
1255
+ {
1256
+ "g": "Rotation Pools",
1257
+ "icon": "🔄",
1258
+ "k": "OPENROUTER_API_KEYS",
1259
+ "lbl": "OpenRouter pool",
1260
+ "type": "text"
1261
+ },
1262
+ {
1263
+ "g": "Rotation Pools",
1264
+ "icon": "🔄",
1265
+ "k": "OPENCODE_API_KEYS",
1266
+ "lbl": "OpenCode pool",
1267
+ "type": "text"
1268
+ },
1269
+ {
1270
+ "g": "Rotation Pools",
1271
+ "icon": "🔄",
1272
+ "k": "KILOCODE_API_KEYS",
1273
+ "lbl": "KiloCode pool",
1274
+ "type": "text"
1275
+ },
1276
+ {
1277
+ "g": "Rotation Pools",
1278
+ "icon": "🔄",
1279
+ "k": "ZAI_API_KEYS",
1280
+ "lbl": "Z.ai / GLM pool",
1281
+ "type": "text"
1282
+ },
1283
+ {
1284
+ "g": "Rotation Pools",
1285
+ "icon": "🔄",
1286
+ "k": "MOONSHOT_API_KEYS",
1287
+ "lbl": "Moonshot / Kimi pool",
1288
+ "type": "text"
1289
+ },
1290
+ {
1291
+ "g": "Rotation Pools",
1292
+ "icon": "🔄",
1293
+ "k": "MINIMAX_API_KEYS",
1294
+ "lbl": "MiniMax pool",
1295
+ "type": "text"
1296
+ },
1297
+ {
1298
+ "g": "Rotation Pools",
1299
+ "icon": "🔄",
1300
+ "k": "XIAOMI_API_KEYS",
1301
+ "lbl": "Xiaomi pool",
1302
+ "type": "text"
1303
+ },
1304
+ {
1305
+ "g": "Rotation Pools",
1306
+ "icon": "🔄",
1307
+ "k": "VOLCANO_ENGINE_API_KEYS",
1308
+ "lbl": "Volcano Engine pool",
1309
+ "type": "text"
1310
+ },
1311
+ {
1312
+ "g": "Rotation Pools",
1313
+ "icon": "🔄",
1314
+ "k": "BYTEPLUS_API_KEYS",
1315
+ "lbl": "BytePlus pool",
1316
+ "type": "text"
1317
+ },
1318
+ {
1319
+ "g": "Rotation Pools",
1320
+ "icon": "🔄",
1321
+ "k": "MISTRAL_API_KEYS",
1322
+ "lbl": "Mistral pool",
1323
+ "type": "text"
1324
+ },
1325
+ {
1326
+ "g": "Rotation Pools",
1327
+ "icon": "🔄",
1328
+ "k": "XAI_API_KEYS",
1329
+ "lbl": "xAI pool",
1330
+ "type": "text"
1331
+ },
1332
+ {
1333
+ "g": "Rotation Pools",
1334
+ "icon": "🔄",
1335
+ "k": "NVIDIA_API_KEYS",
1336
+ "lbl": "NVIDIA pool",
1337
+ "type": "text"
1338
+ },
1339
+ {
1340
+ "g": "Rotation Pools",
1341
+ "icon": "🔄",
1342
+ "k": "GROQ_API_KEYS",
1343
+ "lbl": "Groq pool",
1344
+ "type": "text"
1345
+ },
1346
+ {
1347
+ "g": "Rotation Pools",
1348
+ "icon": "🔄",
1349
+ "k": "COHERE_API_KEYS",
1350
+ "lbl": "Cohere pool",
1351
+ "type": "text"
1352
+ },
1353
+ {
1354
+ "g": "Rotation Pools",
1355
+ "icon": "🔄",
1356
+ "k": "TOGETHER_API_KEYS",
1357
+ "lbl": "Together pool",
1358
+ "type": "text"
1359
+ },
1360
+ {
1361
+ "g": "Rotation Pools",
1362
+ "icon": "🔄",
1363
+ "k": "CEREBRAS_API_KEYS",
1364
+ "lbl": "Cerebras pool",
1365
+ "type": "text"
1366
+ },
1367
+ {
1368
+ "g": "Rotation Pools",
1369
+ "icon": "🔄",
1370
+ "k": "HUGGINGFACE_HUB_TOKENS",
1371
+ "lbl": "HF token pool",
1372
+ "type": "text"
1373
+ },
1374
+ {
1375
+ "g": "Model Lists",
1376
+ "icon": "📋",
1377
+ "k": "OPENAI_MODELS",
1378
+ "lbl": "Visible OpenAI models",
1379
+ "type": "model_list",
1380
+ "options_key": "OPENAI_MODELS",
1381
+ "ph": "Select models to build a comma list"
1382
+ },
1383
+ {
1384
+ "g": "Model Lists",
1385
+ "icon": "📋",
1386
+ "k": "ANTHROPIC_MODELS",
1387
+ "lbl": "Visible Anthropic models",
1388
+ "type": "model_list",
1389
+ "options_key": "ANTHROPIC_MODELS",
1390
+ "ph": "Select models to build a comma list"
1391
+ },
1392
+ {
1393
+ "g": "Model Lists",
1394
+ "icon": "📋",
1395
+ "k": "GEMINI_MODELS",
1396
+ "lbl": "Visible Gemini models",
1397
+ "type": "model_list",
1398
+ "options_key": "GEMINI_MODELS",
1399
+ "ph": "Select models to build a comma list"
1400
+ },
1401
+ {
1402
+ "g": "Model Lists",
1403
+ "icon": "📋",
1404
+ "k": "DEEPSEEK_MODELS",
1405
+ "lbl": "Visible DeepSeek models",
1406
+ "type": "model_list",
1407
+ "options_key": "DEEPSEEK_MODELS",
1408
+ "ph": "Select models to build a comma list"
1409
+ },
1410
+ {
1411
+ "g": "Model Lists",
1412
+ "icon": "📋",
1413
+ "k": "OPENROUTER_MODELS",
1414
+ "lbl": "Visible OpenRouter models",
1415
+ "type": "model_list",
1416
+ "options_key": "OPENROUTER_MODELS",
1417
+ "ph": "Select models to build a comma list"
1418
+ },
1419
+ {
1420
+ "g": "Model Lists",
1421
+ "icon": "📋",
1422
+ "k": "GROQ_MODELS",
1423
+ "lbl": "Visible Groq models",
1424
+ "type": "model_list",
1425
+ "options_key": "GROQ_MODELS",
1426
+ "ph": "Select models to build a comma list"
1427
+ },
1428
+ {
1429
+ "g": "Model Lists",
1430
+ "icon": "📋",
1431
+ "k": "MISTRAL_MODELS",
1432
+ "lbl": "Visible Mistral models",
1433
+ "type": "model_list",
1434
+ "options_key": "MISTRAL_MODELS",
1435
+ "ph": "Select models to build a comma list"
1436
+ },
1437
+ {
1438
+ "g": "Model Lists",
1439
+ "icon": "📋",
1440
+ "k": "XAI_MODELS",
1441
+ "lbl": "Visible xAI models",
1442
+ "type": "model_list",
1443
+ "options_key": "XAI_MODELS",
1444
+ "ph": "Select models to build a comma list"
1445
+ },
1446
+ {
1447
+ "g": "Model Lists",
1448
+ "icon": "📋",
1449
+ "k": "COHERE_MODELS",
1450
+ "lbl": "Visible Cohere models",
1451
+ "type": "model_list",
1452
+ "options_key": "COHERE_MODELS",
1453
+ "ph": "Select models to build a comma list"
1454
+ },
1455
+ {
1456
+ "g": "Model Lists",
1457
+ "icon": "📋",
1458
+ "k": "TOGETHER_MODELS",
1459
+ "lbl": "Visible Together models",
1460
+ "type": "model_list",
1461
+ "options_key": "TOGETHER_MODELS",
1462
+ "ph": "Select models to build a comma list"
1463
+ },
1464
+ {
1465
+ "g": "Model Lists",
1466
+ "icon": "📋",
1467
+ "k": "CEREBRAS_MODELS",
1468
+ "lbl": "Visible Cerebras models",
1469
+ "type": "model_list",
1470
+ "options_key": "CEREBRAS_MODELS",
1471
+ "ph": "Select models to build a comma list"
1472
+ },
1473
+ {
1474
+ "g": "Model Lists",
1475
+ "icon": "📋",
1476
+ "k": "NVIDIA_MODELS",
1477
+ "lbl": "Visible NVIDIA models",
1478
+ "type": "model_list",
1479
+ "options_key": "NVIDIA_MODELS",
1480
+ "ph": "Select models to build a comma list"
1481
+ },
1482
+ {
1483
+ "g": "Model Lists",
1484
+ "icon": "📋",
1485
+ "k": "KILOCODE_MODELS",
1486
+ "lbl": "Visible KiloCode models",
1487
+ "type": "model_list",
1488
+ "options_key": "KILOCODE_MODELS",
1489
+ "ph": "Select models to build a comma list"
1490
+ },
1491
+ {
1492
+ "g": "Model Lists",
1493
+ "icon": "📋",
1494
+ "k": "OPENCODE_MODELS",
1495
+ "lbl": "Visible OpenCode models",
1496
+ "type": "model_list",
1497
+ "options_key": "OPENCODE_MODELS",
1498
+ "ph": "Select models to build a comma list"
1499
+ },
1500
+ {
1501
+ "g": "Model Lists",
1502
+ "icon": "📋",
1503
+ "k": "ZAI_MODELS",
1504
+ "lbl": "Visible Z.ai / GLM models",
1505
+ "type": "model_list",
1506
+ "options_key": "ZAI_MODELS",
1507
+ "ph": "Select models to build a comma list"
1508
+ },
1509
+ {
1510
+ "g": "Model Lists",
1511
+ "icon": "📋",
1512
+ "k": "MOONSHOT_MODELS",
1513
+ "lbl": "Visible Moonshot / Kimi models",
1514
+ "type": "model_list",
1515
+ "options_key": "MOONSHOT_MODELS",
1516
+ "ph": "Select models to build a comma list"
1517
+ },
1518
+ {
1519
+ "g": "Model Lists",
1520
+ "icon": "📋",
1521
+ "k": "MINIMAX_MODELS",
1522
+ "lbl": "Visible MiniMax models",
1523
+ "type": "model_list",
1524
+ "options_key": "MINIMAX_MODELS",
1525
+ "ph": "Select models to build a comma list"
1526
+ },
1527
+ {
1528
+ "g": "Model Lists",
1529
+ "icon": "📋",
1530
+ "k": "XIAOMI_MODELS",
1531
+ "lbl": "Visible Xiaomi models",
1532
+ "type": "model_list",
1533
+ "options_key": "XIAOMI_MODELS",
1534
+ "ph": "Select models to build a comma list"
1535
+ },
1536
+ {
1537
+ "g": "Model Lists",
1538
+ "icon": "📋",
1539
+ "k": "VOLCANO_ENGINE_MODELS",
1540
+ "lbl": "Visible Volcano Engine models",
1541
+ "type": "model_list",
1542
+ "options_key": "VOLCANO_ENGINE_MODELS",
1543
+ "ph": "Select models to build a comma list"
1544
+ },
1545
+ {
1546
+ "g": "Model Lists",
1547
+ "icon": "📋",
1548
+ "k": "BYTEPLUS_MODELS",
1549
+ "lbl": "Visible BytePlus models",
1550
+ "type": "model_list",
1551
+ "options_key": "BYTEPLUS_MODELS",
1552
+ "ph": "Select models to build a comma list"
1553
+ },
1554
+ {
1555
+ "g": "Model Lists",
1556
+ "icon": "📋",
1557
+ "k": "QIANFAN_MODELS",
1558
+ "lbl": "Visible Qianfan models",
1559
+ "type": "model_list",
1560
+ "options_key": "QIANFAN_MODELS",
1561
+ "ph": "Select models to build a comma list"
1562
+ },
1563
+ {
1564
+ "g": "Model Lists",
1565
+ "icon": "📋",
1566
+ "k": "MODELSTUDIO_MODELS",
1567
+ "lbl": "Visible ModelStudio models",
1568
+ "type": "model_list",
1569
+ "options_key": "MODELSTUDIO_MODELS",
1570
+ "ph": "Select models to build a comma list"
1571
+ },
1572
+ {
1573
+ "g": "Model Lists",
1574
+ "icon": "📋",
1575
+ "k": "KIMI_MODELS",
1576
+ "lbl": "Visible Kimi models",
1577
+ "type": "model_list",
1578
+ "options_key": "KIMI_MODELS",
1579
+ "ph": "Select models to build a comma list"
1580
+ },
1581
+ {
1582
+ "g": "Model Lists",
1583
+ "icon": "📋",
1584
+ "k": "HUGGINGFACE_MODELS",
1585
+ "lbl": "Visible Hugging Face models",
1586
+ "type": "model_list",
1587
+ "options_key": "HUGGINGFACE_MODELS",
1588
+ "ph": "Select models to build a comma list"
1589
+ },
1590
+ {
1591
+ "g": "Model Lists",
1592
+ "icon": "📋",
1593
+ "k": "GITHUB_COPILOT_MODELS",
1594
+ "lbl": "Visible GitHub Copilot models",
1595
+ "type": "model_list",
1596
+ "options_key": "GITHUB_COPILOT_MODELS",
1597
+ "ph": "Select models to build a comma list"
1598
+ },
1599
+ {
1600
+ "g": "Custom Provider",
1601
+ "icon": "🔌",
1602
+ "k": "CUSTOM_PROVIDER_NAME",
1603
+ "lbl": "Provider display name",
1604
+ "type": "text"
1605
+ },
1606
+ {
1607
+ "g": "Custom Provider",
1608
+ "icon": "🔌",
1609
+ "k": "CUSTOM_BASE_URL",
1610
+ "lbl": "OpenAI-compatible base URL",
1611
+ "type": "text"
1612
+ },
1613
+ {
1614
+ "g": "Custom Provider",
1615
+ "icon": "🔌",
1616
+ "k": "CUSTOM_MODEL_ID",
1617
+ "lbl": "Model ID",
1618
+ "type": "model",
1619
+ "options_key": "LLM_MODEL",
1620
+ "ph": "custom model id"
1621
+ },
1622
+ {
1623
+ "g": "Custom Provider",
1624
+ "icon": "🔌",
1625
+ "k": "CUSTOM_MODEL_NAME",
1626
+ "lbl": "Friendly model name",
1627
+ "type": "text"
1628
+ },
1629
+ {
1630
+ "g": "Custom Provider",
1631
+ "icon": "🔌",
1632
+ "k": "CUSTOM_API_KEY",
1633
+ "lbl": "Provider API key",
1634
+ "type": "password",
1635
+ "secret": 1
1636
+ },
1637
+ {
1638
+ "g": "Custom Provider",
1639
+ "icon": "🔌",
1640
+ "k": "CUSTOM_API_TYPE",
1641
+ "lbl": "API type",
1642
+ "type": "select",
1643
+ "options": [
1644
+ "openai-completions",
1645
+ "openai-chat-completions",
1646
+ "anthropic",
1647
+ "gemini",
1648
+ "openrouter"
1649
+ ],
1650
+ "ph": "openai-completions"
1651
+ },
1652
+ {
1653
+ "g": "Custom Provider",
1654
+ "icon": "🔌",
1655
+ "k": "CUSTOM_CONTEXT_WINDOW",
1656
+ "lbl": "Context window",
1657
+ "type": "number",
1658
+ "ph": "128000"
1659
+ },
1660
+ {
1661
+ "g": "Custom Provider",
1662
+ "icon": "🔌",
1663
+ "k": "CUSTOM_MAX_TOKENS",
1664
+ "lbl": "Max output tokens",
1665
+ "type": "number",
1666
+ "ph": "500"
1667
+ },
1668
+ {
1669
+ "g": "Telegram",
1670
+ "icon": "✈️",
1671
+ "k": "TELEGRAM_BOT_TOKEN",
1672
+ "lbl": "Bot token from BotFather",
1673
+ "type": "password",
1674
+ "secret": 1,
1675
+ "common": 1
1676
+ },
1677
+ {
1678
+ "g": "Telegram",
1679
+ "icon": "✈️",
1680
+ "k": "TELEGRAM_ALLOWED_USERS",
1681
+ "lbl": "Allowed user IDs (comma)",
1682
+ "type": "text",
1683
+ "ph": "123456789,987654321",
1684
+ "common": 1
1685
+ },
1686
+ {
1687
+ "g": "Telegram",
1688
+ "icon": "✈️",
1689
+ "k": "TELEGRAM_USER_ID",
1690
+ "lbl": "Single Telegram user ID",
1691
+ "type": "text"
1692
+ },
1693
+ {
1694
+ "g": "Telegram",
1695
+ "icon": "✈️",
1696
+ "k": "TELEGRAM_USER_IDS",
1697
+ "lbl": "Telegram user IDs (comma)",
1698
+ "type": "text"
1699
+ },
1700
+ {
1701
+ "g": "Deployment",
1702
+ "icon": "🧭",
1703
+ "k": "APP_BASE",
1704
+ "lbl": "Public app base path",
1705
+ "type": "text",
1706
+ "ph": "/app"
1707
+ },
1708
+ {
1709
+ "g": "Deployment",
1710
+ "icon": "🧭",
1711
+ "k": "BACKUP_DATASET",
1712
+ "lbl": "Backup dataset alias",
1713
+ "type": "text",
1714
+ "ph": "huggingclaw-backup"
1715
+ },
1716
+ {
1717
+ "g": "Deployment",
1718
+ "icon": "🧭",
1719
+ "k": "SPACE_AUTHOR_NAME",
1720
+ "lbl": "HF Space author name",
1721
+ "type": "text"
1722
+ },
1723
+ {
1724
+ "g": "Deployment",
1725
+ "icon": "🧭",
1726
+ "k": "SPACE_HOST",
1727
+ "lbl": "HF Space host domain",
1728
+ "type": "text"
1729
+ },
1730
+ {
1731
+ "g": "Deployment",
1732
+ "icon": "🧭",
1733
+ "k": "PORT",
1734
+ "lbl": "Public dashboard port",
1735
+ "type": "number",
1736
+ "ph": "7861"
1737
+ },
1738
+ {
1739
+ "g": "Deployment",
1740
+ "icon": "🧭",
1741
+ "k": "GATEWAY_PORT",
1742
+ "lbl": "OpenClaw internal port",
1743
+ "type": "number",
1744
+ "ph": "7860"
1745
+ },
1746
+ {
1747
+ "g": "Deployment",
1748
+ "icon": "🧭",
1749
+ "k": "JUPYTER_PORT",
1750
+ "lbl": "Jupyter internal port",
1751
+ "type": "number",
1752
+ "ph": "8888"
1753
+ },
1754
+ {
1755
+ "g": "Deployment",
1756
+ "icon": "🧭",
1757
+ "k": "JUPYTER_BASE",
1758
+ "lbl": "Jupyter public base path",
1759
+ "type": "text",
1760
+ "ph": "/terminal"
1761
+ }
1762
+ ];
1763
+
1764
+ const ICONS = {
1765
+ All:'🏠', Core:'⚡', Deployment:'🧭', 'Provider Keys':'🔑', 'Rotation Pools':'🔄',
1766
+ 'Model Lists':'📋', 'Custom Provider':'🔌', Telegram:'✈️', WhatsApp:'💬',
1767
+ Cloudflare:'☁️', Backup:'💾', DevData:'🧪', Runtime:'⚙️', 'Custom Env':'🔧'
1768
+ };
1769
+
1770
+ const $ = id => document.getElementById(id);
1771
+ const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({
1772
+ '&': '&amp;',
1773
+ '<': '&lt;',
1774
+ '>': '&gt;',
1775
+ '"': '&quot;',
1776
+ "'": '&#39;'
1777
+ }[c]));
1778
+ const safeKey = k => /^[A-Z_][A-Z0-9_]*$/.test(k) && !['HUGGINGCLAW_ENV_BUNDLE', 'ENV_BUNDLE'].includes(k);
1779
+
1780
+ function encodeBundle(obj) {
1781
+ const j = JSON.stringify(obj);
1782
+ let b = '';
1783
+ for (const x of new TextEncoder().encode(j)) b += String.fromCharCode(x);
1784
+ return btoa(b).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
1785
+ }
1786
+
1787
+ function decodeBundle(raw) {
1788
+ try {
1789
+ raw = String(raw || '').trim();
1790
+ if (!raw) return {};
1791
+
1792
+ if (raw.includes('HUGGINGCLAW_ENV_BUNDLE=')) {
1793
+ raw = raw.split('HUGGINGCLAW_ENV_BUNDLE=').pop().trim();
1794
+ }
1795
+
1796
+ if (
1797
+ (raw.startsWith('"') && raw.endsWith('"')) ||
1798
+ (raw.startsWith("'") && raw.endsWith("'"))
1799
+ ) {
1800
+ raw = raw.slice(1, -1);
1801
+ }
1802
+
1803
+ if (raw.startsWith('{')) return JSON.parse(raw);
1804
+
1805
+ const p = raw + '='.repeat((4 - raw.length % 4) % 4);
1806
+ const b = atob(p.replace(/-/g, '+').replace(/_/g, '/'));
1807
+ const bytes = Uint8Array.from(b, c => c.charCodeAt(0));
1808
+ return JSON.parse(new TextDecoder().decode(bytes));
1809
+ } catch {
1810
+ return {};
1811
+ }
1812
+ }
1813
+
1814
+ function parseEnv(text) {
1815
+ text = String(text || '').trim();
1816
+ if (!text) return {};
1817
+
1818
+ if (
1819
+ text.startsWith('{') ||
1820
+ /^[A-Za-z0-9_-]{20,}$/.test(text) ||
1821
+ text.includes('HUGGINGCLAW_ENV_BUNDLE=')
1822
+ ) {
1823
+ return decodeBundle(text);
1824
+ }
1825
+
1826
+ const out = {};
1827
+ for (let line of text.split(/\r?\n/)) {
1828
+ line = line.trim();
1829
+ if (!line || line.startsWith('#')) continue;
1830
+ if (line.startsWith('export ')) line = line.slice(7).trim();
1831
+ const i = line.indexOf('=');
1832
+ if (i < 1) continue;
1833
+
1834
+ const key = line.slice(0, i).trim();
1835
+ let val = line.slice(i + 1).trim();
1836
+
1837
+ if (
1838
+ (val.startsWith('"') && val.endsWith('"')) ||
1839
+ (val.startsWith("'") && val.endsWith("'"))
1840
+ ) {
1841
+ val = val.slice(1, -1);
1842
+ }
1843
+
1844
+ if (safeKey(key)) out[key] = val;
1845
+ }
1846
+ return out;
1847
+ }
1848
+
1849
+ function showToast(msg = 'Copied!') {
1850
+ const t = $('toast');
1851
+ t.textContent = msg;
1852
+ t.classList.add('show');
1853
+ setTimeout(() => t.classList.remove('show'), 1500);
1854
+ }
1855
+
1856
+ let activeGroup = 'All';
1857
+ let customCount = 0;
1858
+ const GROUPS = ['All', ...[...new Set(FIELDS.map(f => f.g))], 'Custom Env'];
1859
+
1860
+ function renderSidebar() {
1861
+ const sb = $('sidebar');
1862
+ sb.innerHTML = '<div class="sb-label">Groups</div>';
1863
+ GROUPS.forEach(g => {
1864
+ const btn = document.createElement('button');
1865
+ btn.className = 'nav-btn' + (activeGroup === g ? ' active' : '');
1866
+ btn.dataset.group = g;
1867
+ const id = 'nc_' + g.replace(/\W/g, '_');
1868
+ btn.innerHTML = `<span class="nav-icon">${ICONS[g] || '📁'}</span><span class="nav-label">${esc(g)}</span><span class="nav-count" id="${id}">0</span>`;
1869
+ btn.onclick = () => {
1870
+ activeGroup = g;
1871
+ renderSidebar();
1872
+ filter();
1873
+ };
1874
+ sb.appendChild(btn);
1875
+ });
1876
+ }
1877
+
1878
+ function renderOptionsHTML(field) {
1879
+ const src = field.options || MODEL_CATALOGS[field.options_key] || [];
1880
+
1881
+ if (field.options_key === 'LLM_MODEL') {
1882
+ const groups = MODEL_CATALOGS.LLM_MODEL || {};
1883
+ return Object.entries(groups).map(([group, items]) => {
1884
+ const options = items.map(v => `<option value="${esc(v)}">${esc(v)}</option>`).join('');
1885
+ return `<optgroup label="${esc(group)}">${options}</optgroup>`;
1886
+ }).join('');
1887
+ }
1888
+
1889
+ if (Array.isArray(src)) {
1890
+ return src.map(v => `<option value="${esc(v)}">${esc(v)}</option>`).join('');
1891
+ }
1892
+
1893
+ return '';
1894
+ }
1895
+
1896
+ function defaultValueFor(field) {
1897
+ if (field.type === 'toggle') {
1898
+ const on = String(field.ph ?? '').toLowerCase();
1899
+ return ['1', 'true', 'yes', 'on', 'enabled'].includes(on) ? 'true' : 'false';
1900
+ }
1901
+ if (field.type === 'select') return String(field.ph ?? '');
1902
+ return '';
1903
+ }
1904
+
1905
+ function valueControlHTML(field) {
1906
+ const key = esc(field.k);
1907
+ const placeholder = esc(field.ph || field.lbl || '');
1908
+ const isSecret = !!field.secret;
1909
+ const isTextarea = field.type === 'textarea' || field.type === 'model_list';
1910
+ const hasPicker = !!field.options_key || Array.isArray(field.options);
1911
+ const inputType = isSecret ? 'password' : (field.type === 'number' ? 'number' : 'text');
1912
+
1913
+ let control = '';
1914
+ if (field.type === 'toggle') {
1915
+ const initial = defaultValueFor(field);
1916
+ control = `
1917
+ <div class="toggle-shell" data-toggle-row="1" data-field="${key}">
1918
+ <input type="hidden" data-key="${key}" value="${initial}">
1919
+ <button type="button" class="tog ${initial === 'true' ? 'on' : ''}" data-toggle="${key}">${initial === 'true' ? 'On' : 'Off'}</button>
1920
+ </div>`;
1921
+ } else if (isTextarea) {
1922
+ control = `<textarea data-key="${key}" placeholder="${placeholder}" spellcheck="false"></textarea>`;
1923
+ } else {
1924
+ control = `<input type="${inputType}" data-key="${key}" placeholder="${placeholder}" spellcheck="false"/>`;
1925
+ }
1926
+
1927
+ if (!hasPicker) return control;
1928
+
1929
+ const pickerMode = field.type === 'model_list' ? 'multi' : 'single';
1930
+ const pickerLabel = field.type === 'model_list' ? 'Add model…' : 'Choose preset…';
1931
+ return `
1932
+ <div class="picker-shell" data-picker-shell="${key}" data-picker-mode="${pickerMode}">
1933
+ <div class="picker-row">
1934
+ <select class="picker-select" data-pick-for="${key}" aria-label="${esc(field.lbl || field.k)} presets">
1935
+ <option value="">${esc(pickerLabel)}</option>
1936
+ ${renderOptionsHTML(field)}
1937
+ <option value="__custom__">Custom…</option>
1938
+ </select>
1939
+ <button type="button" class="mini-btn" data-custom-for="${key}">+ Custom</button>
1940
+ <button type="button" class="mini-btn" data-clear-for="${key}">Clear</button>
1941
+ </div>
1942
+ ${control}
1943
+ </div>`;
1944
+
1945
+ return control;
1946
+ }
1947
+
1948
+ function cardHTML(f) {
1949
+ const badge = f.secret
1950
+ ? '<span class="badge badge-s">secret</span>'
1951
+ : '<span class="badge badge-f">safe</span>';
1952
+
1953
+ return `<div class="env-card" data-row data-group="${esc(f.g)}" data-search="${esc((f.g + ' ' + f.k + ' ' + (f.lbl || '')).toLowerCase())}">
1954
+ <div class="card-top">
1955
+ <input type="checkbox" class="card-check" data-check="${esc(f.k)}" ${f.common ? 'data-common="1"' : ''}>
1956
+ <div class="card-info">
1957
+ <div class="card-key">${esc(f.k)}</div>
1958
+ <div class="card-lbl">${esc(f.lbl || '')}</div>
1959
+ </div>
1960
+ ${badge}
1961
+ </div>
1962
+ <div class="card-input">${valueControlHTML(f)}</div>
1963
+ </div>`;
1964
+ }
1965
+
1966
+ function addCustomRow(key = '', val = '', enabled = false) {
1967
+ const id = customCount++;
1968
+ const row = document.createElement('div');
1969
+ row.className = 'custom-row';
1970
+ row.dataset.customRow = id;
1971
+ row.dataset.enabled = enabled ? '1' : '0';
1972
+
1973
+ row.innerHTML = `
1974
+ <input data-ck="${id}" placeholder="CUSTOM_ENV_NAME" value="${esc(key)}">
1975
+ <input data-cv="${id}" placeholder="value" value="${esc(val)}">
1976
+ <button class="tog${enabled ? ' on' : ''}">${enabled ? 'On' : 'Off'}</button>
1977
+ `;
1978
+
1979
+ $('customRows').appendChild(row);
1980
+
1981
+ row.querySelectorAll('input').forEach(el => el.addEventListener('input', refresh));
1982
+ row.querySelector('button').onclick = () => {
1983
+ const on = row.dataset.enabled !== '1';
1984
+ row.dataset.enabled = on ? '1' : '0';
1985
+ row.querySelector('button').textContent = on ? 'On' : 'Off';
1986
+ row.querySelector('button').classList.toggle('on', on);
1987
+ refresh();
1988
+ };
1989
+ }
1990
+
1991
+ function getFieldValueInput(key) {
1992
+ return document.querySelector(`[data-key="${CSS.escape(key)}"]`);
1993
+ }
1994
+
1995
+ function setFieldValue(key, value) {
1996
+ const el = getFieldValueInput(key);
1997
+ if (!el) return;
1998
+ el.value = value ?? '';
1999
+ }
2000
+
2001
+ function appendCsvValue(existing, next) {
2002
+ const parts = String(existing || '').split(',').map(s => s.trim()).filter(Boolean);
2003
+ const val = String(next || '').trim();
2004
+ if (!val) return parts.join(', ');
2005
+ if (!parts.includes(val)) parts.push(val);
2006
+ return parts.join(', ');
2007
+ }
2008
+
2009
+ function collect() {
2010
+ const obj = {};
2011
+ document.querySelectorAll('[data-key]').forEach(el => {
2012
+ const key = el.dataset.key;
2013
+ if (!key || !safeKey(key)) return;
2014
+ // Only include if the card's checkbox is ticked
2015
+ const chk = document.querySelector(`[data-check="${CSS.escape(key)}"]`);
2016
+ if (!chk || !chk.checked) return;
2017
+ const val = String(el.value ?? '').trim();
2018
+ if (val) obj[key] = val;
2019
+ });
2020
+
2021
+ document.querySelectorAll('[data-custom-row]').forEach(row => {
2022
+ const id = row.dataset.customRow;
2023
+ const key = (row.querySelector(`[data-ck="${id}"]`)?.value || '').trim();
2024
+ const val = (row.querySelector(`[data-cv="${id}"]`)?.value || '').trim();
2025
+ if (row.dataset.enabled === '1' && safeKey(key) && val) obj[key] = val;
2026
+ });
2027
+
2028
+ return obj;
2029
+ }
2030
+
2031
+ function refresh() {
2032
+ const obj = collect();
2033
+ const keys = Object.keys(obj).sort();
2034
+ const bundle = keys.length ? encodeBundle(Object.fromEntries(keys.map(k => [k, obj[k]]))) : '';
2035
+
2036
+ $('bundleOut').value = bundle;
2037
+ $('envLineOut').value = bundle ? `HUGGINGCLAW_ENV_BUNDLE=${bundle}` : '';
2038
+
2039
+ const s = $('summary');
2040
+ if (keys.length) {
2041
+ s.innerHTML = `<strong>${keys.length}</strong> variable${keys.length > 1 ? 's' : ''} selected<div class="sum-keys">${keys.map(k => `<span class="sum-key">${esc(k)}</span>`).join('')}</div>`;
2042
+ } else {
2043
+ s.innerHTML = 'No variables selected yet.';
2044
+ }
2045
+ updateCounts();
2046
+ }
2047
+
2048
+ function markSelected() {
2049
+ document.querySelectorAll('[data-row]').forEach(r => r.classList.toggle('selected', !!r.querySelector('[data-check]')?.checked));
2050
+ }
2051
+
2052
+ function updateCounts() {
2053
+ document.querySelectorAll('[id^="nc_"]').forEach(el => el.textContent = '0');
2054
+ const byGrp = {};
2055
+ document.querySelectorAll('[data-check]:checked').forEach(ch => {
2056
+ const g = ch.closest('[data-row]')?.dataset.group;
2057
+ if (g) byGrp[g] = (byGrp[g] || 0) + 1;
2058
+ });
2059
+ const custOn = document.querySelectorAll('[data-custom-row][data-enabled="1"]').length;
2060
+ const total = Object.values(byGrp).reduce((a, b) => a + b, 0) + custOn;
2061
+ const allEl = document.getElementById('nc_All'); if (allEl) allEl.textContent = total;
2062
+ Object.entries(byGrp).forEach(([g, c]) => {
2063
+ const el = document.getElementById('nc_' + g.replace(/\W/g, '_'));
2064
+ if (el) el.textContent = c;
2065
+ });
2066
+ const custEl = document.getElementById('nc_Custom_Env'); if (custEl) custEl.textContent = custOn;
2067
+ }
2068
+
2069
+ function filter() {
2070
+ const q = $('search').value.trim().toLowerCase();
2071
+ document.querySelectorAll('.sec[data-section]').forEach(sec => {
2072
+ const grp = sec.dataset.section;
2073
+ const gMatch = activeGroup === 'All' || activeGroup === grp;
2074
+ if (!gMatch) { sec.classList.add('sec-hidden'); return; }
2075
+ let any = false;
2076
+ sec.querySelectorAll('[data-row]').forEach(card => {
2077
+ const m = !q || card.dataset.search.includes(q);
2078
+ card.classList.toggle('hidden', !m);
2079
+ if (m) any = true;
2080
+ });
2081
+ sec.classList.toggle('sec-hidden', !any);
2082
+ });
2083
+ const cs = $('customSec');
2084
+ if (cs) cs.style.display = (activeGroup === 'All' || activeGroup === 'Custom Env') ? '' : 'none';
2085
+ document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.group === activeGroup));
2086
+ }
2087
+
2088
+ function clearForm() {
2089
+ document.querySelectorAll('[data-check]').forEach(c => c.checked = false);
2090
+ document.querySelectorAll('[data-key]').forEach(el => {
2091
+ if (el.closest('[data-toggle-row]')) {
2092
+ el.value = 'false';
2093
+ const btn = el.closest('.toggle-shell')?.querySelector('[data-toggle]');
2094
+ if (btn) {
2095
+ btn.textContent = 'Off';
2096
+ btn.classList.remove('on');
2097
+ }
2098
+ return;
2099
+ }
2100
+ el.value = '';
2101
+ });
2102
+ $('customRows').innerHTML = '';
2103
+ customCount = 0;
2104
+ addCustomRow();
2105
+ }
2106
+
2107
+ function applyObj(obj, replace = false) {
2108
+ if (replace) clearForm();
2109
+ for (const [key, val] of Object.entries(obj || {})) {
2110
+ if (!safeKey(key)) continue;
2111
+ const inp = getFieldValueInput(key);
2112
+ const chk = document.querySelector(`[data-check="${CSS.escape(key)}"]`);
2113
+ if (inp && chk) {
2114
+ inp.value = val;
2115
+ chk.checked = true;
2116
+ const btn = inp.closest('[data-toggle-row]')?.querySelector('[data-toggle]');
2117
+ if (btn) {
2118
+ const on = String(val).trim().toLowerCase() === 'true';
2119
+ btn.textContent = on ? 'On' : 'Off';
2120
+ btn.classList.toggle('on', on);
2121
+ inp.value = on ? 'true' : 'false';
2122
+ }
2123
+ } else {
2124
+ addCustomRow(key, val, true);
2125
+ }
2126
+ }
2127
+ markSelected(); filter(); refresh();
2128
+ }
2129
+
2130
+ function autoCheck(key) {
2131
+ const chk = document.querySelector(`[data-check="${CSS.escape(key)}"]`);
2132
+ if (chk && !chk.checked) {
2133
+ chk.checked = true;
2134
+ markSelected();
2135
+ }
2136
+ }
2137
+
2138
+ function handlePickerChange(sel) {
2139
+ const key = sel.dataset.pickFor;
2140
+ const mode = sel.closest('[data-picker-shell]')?.dataset.pickerMode || 'single';
2141
+ const value = sel.value;
2142
+ if (!key || !value) return;
2143
+ if (value === '__custom__') {
2144
+ sel.value = '';
2145
+ return;
2146
+ }
2147
+ const inp = getFieldValueInput(key);
2148
+ if (!inp) return;
2149
+
2150
+ if (mode === 'multi') {
2151
+ inp.value = appendCsvValue(inp.value, value);
2152
+ } else {
2153
+ inp.value = value;
2154
+ }
2155
+ sel.value = '';
2156
+ autoCheck(key);
2157
+ refresh();
2158
+ }
2159
+
2160
+ function promptCustomModel(btn) {
2161
+ const key = btn.dataset.customFor;
2162
+ const mode = btn.closest('[data-picker-shell]')?.dataset.pickerMode || 'single';
2163
+ const inp = getFieldValueInput(key);
2164
+ if (!inp) return;
2165
+ const message = mode === 'multi'
2166
+ ? 'Enter one or more custom model IDs separated by commas'
2167
+ : 'Enter a custom model ID';
2168
+ const initial = '';
2169
+ const text = prompt(message, initial);
2170
+ if (text === null) return;
2171
+ const val = String(text).trim();
2172
+ if (!val) return;
2173
+ if (mode === 'multi') {
2174
+ const vals = val.split(',').map(s => s.trim()).filter(Boolean);
2175
+ let out = inp.value || '';
2176
+ for (const v of vals) out = appendCsvValue(out, v);
2177
+ inp.value = out;
2178
+ } else {
2179
+ inp.value = val;
2180
+ }
2181
+ autoCheck(key);
2182
+ refresh();
2183
+ }
2184
+
2185
+ function resetPickerField(btn) {
2186
+ const key = btn.dataset.clearFor;
2187
+ const inp = getFieldValueInput(key);
2188
+ if (!inp) return;
2189
+ if (inp.closest('[data-toggle-row]')) {
2190
+ inp.value = 'false';
2191
+ const toggleBtn = inp.closest('.toggle-shell')?.querySelector('[data-toggle]');
2192
+ if (toggleBtn) {
2193
+ toggleBtn.textContent = 'Off';
2194
+ toggleBtn.classList.remove('on');
2195
+ }
2196
+ } else {
2197
+ inp.value = '';
2198
+ }
2199
+ refresh();
2200
+ }
2201
+
2202
+ function toggleField(key) {
2203
+ const inp = getFieldValueInput(key);
2204
+ if (!inp) return;
2205
+ const on = String(inp.value || '').trim().toLowerCase() !== 'true';
2206
+ inp.value = on ? 'true' : 'false';
2207
+ const btn = inp.closest('.toggle-shell')?.querySelector('[data-toggle]');
2208
+ if (btn) {
2209
+ btn.textContent = on ? 'On' : 'Off';
2210
+ btn.classList.toggle('on', on);
2211
+ }
2212
+ // Auto-check when turned on; uncheck when turned off
2213
+ const chk = document.querySelector(`[data-check="${CSS.escape(key)}"]`);
2214
+ if (chk) {
2215
+ chk.checked = on;
2216
+ markSelected();
2217
+ }
2218
+ refresh();
2219
+ }
2220
+
2221
+ function bindFieldEvents() {
2222
+ document.querySelectorAll('[data-check]').forEach(el => el.addEventListener('change', () => { markSelected(); refresh(); }));
2223
+ document.querySelectorAll('[data-key]').forEach(el => el.addEventListener('input', refresh));
2224
+ document.querySelectorAll('[data-toggle]').forEach(btn => btn.addEventListener('click', () => toggleField(btn.dataset.toggle)));
2225
+ document.querySelectorAll('[data-pick-for]').forEach(sel => sel.addEventListener('change', () => handlePickerChange(sel)));
2226
+ document.querySelectorAll('[data-custom-for]').forEach(btn => btn.addEventListener('click', () => promptCustomModel(btn)));
2227
+ document.querySelectorAll('[data-clear-for]').forEach(btn => btn.addEventListener('click', () => resetPickerField(btn)));
2228
+ }
2229
+
2230
+ function renderSections() {
2231
+ const grouped = {};
2232
+ FIELDS.forEach(f => { (grouped[f.g] ||= []).push(f); });
2233
+
2234
+ const wrap = $('sections');
2235
+ wrap.innerHTML = '';
2236
+ Object.entries(grouped).forEach(([grp, items]) => {
2237
+ const sec = document.createElement('div');
2238
+ sec.className = 'sec';
2239
+ sec.dataset.section = grp;
2240
+ sec.innerHTML = `
2241
+ <div class="sec-header">
2242
+ <span class="sec-icon">${ICONS[grp] || '📁'}</span>
2243
+ <span class="sec-title">${esc(grp)}</span>
2244
+ <div class="sec-line"></div>
2245
+ </div>
2246
+ <div class="cards">${items.map(cardHTML).join('')}</div>`;
2247
+ wrap.appendChild(sec);
2248
+ });
2249
+ bindFieldEvents();
2250
+ }
2251
+
2252
+ function copyText(text) {
2253
+ return navigator.clipboard.writeText(text).then(
2254
+ () => showToast('Copied ✓'),
2255
+ () => {
2256
+ const ta = document.createElement('textarea');
2257
+ ta.value = text;
2258
+ ta.style.position = 'fixed';
2259
+ ta.style.left = '-9999px';
2260
+ document.body.appendChild(ta);
2261
+ ta.select();
2262
+ document.execCommand('copy');
2263
+ ta.remove();
2264
+ showToast('Copied ✓');
2265
+ }
2266
+ );
2267
+ }
2268
+
2269
+ // ── Init ──
2270
+ renderSidebar();
2271
+ renderSections();
2272
+ addCustomRow();
2273
+ filter();
2274
+ refresh();
2275
+
2276
+ // ── Events ──
2277
+ $('search').oninput = filter;
2278
+ $('selectCommon').onclick = () => {
2279
+ document.querySelectorAll('[data-common="1"]').forEach(c => c.checked = true);
2280
+ markSelected();
2281
+ refresh();
2282
+ };
2283
+ $('selectVisible').onclick = () => {
2284
+ document.querySelectorAll('.sec:not(.sec-hidden) [data-row]:not(.hidden) [data-check]').forEach(c => c.checked = true);
2285
+ markSelected();
2286
+ refresh();
2287
+ };
2288
+ $('clearAll').onclick = () => {
2289
+ clearForm();
2290
+ markSelected();
2291
+ filter();
2292
+ refresh();
2293
+ };
2294
+ $('applyImport').onclick = () => {
2295
+ try {
2296
+ applyObj(parseEnv($('importText').value), true);
2297
+ showToast('Imported ✓');
2298
+ } catch (e) {
2299
+ showToast('Import failed');
2300
+ alert(e.message);
2301
+ }
2302
+ };
2303
+
2304
+ // Auto-import: paste karo aur turant parse + apply ho jaata hai
2305
+ $('importText').addEventListener('paste', () => {
2306
+ setTimeout(() => {
2307
+ try {
2308
+ const val = $('importText').value.trim();
2309
+ if (!val) return;
2310
+ applyObj(parseEnv(val), true);
2311
+ showToast('Auto-imported ✓');
2312
+ } catch (e) {
2313
+ showToast('Import failed');
2314
+ }
2315
+ }, 0);
2316
+ });
2317
+
2318
+ // Live typing: jaise jaise type karo env format mein, bundle banta jaata hai
2319
+ $('importText').addEventListener('input', () => {
2320
+ const val = $('importText').value.trim();
2321
+ if (!val) return;
2322
+ // Sirf agar valid env/bundle format lag raha ho tabhi auto-apply
2323
+ const looksLikeEnv = val.includes('=') || val.startsWith('{') || /^[A-Za-z0-9_\-]{20,}$/.test(val);
2324
+ if (looksLikeEnv) {
2325
+ try {
2326
+ applyObj(parseEnv(val), true);
2327
+ } catch (e) { /* silent — user abhi type kar raha hai */ }
2328
+ }
2329
+ });
2330
+ $('addCustom').onclick = () => addCustomRow();
2331
+ $('applyBundle').onclick = () => {
2332
+ try {
2333
+ applyObj(decodeBundle($('bundleOut').value), true);
2334
+ showToast('Bundle applied ✓');
2335
+ } catch (e) {
2336
+ showToast('Invalid bundle');
2337
+ }
2338
+ };
2339
+ $('copyBundle').onclick = () => copyText($('bundleOut').value);
2340
+ $('copyEnvLine').onclick = () => copyText($('envLineOut').value);
2341
+ $('copyJson').onclick = () => copyText(JSON.stringify(collect(), null, 2));
health-server.js CHANGED
@@ -1,58 +1,126 @@
1
- // Single public entrypoint for HF Spaces: local dashboard + reverse proxy to OpenClaw.
2
  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 startTime = Date.now();
10
  const LLM_MODEL = process.env.LLM_MODEL || "Not Set";
11
  const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN;
12
- const WHATSAPP_ENABLED = /^true$/i.test(process.env.WHATSAPP_ENABLED || "");
13
  const WHATSAPP_STATUS_FILE = "/tmp/huggingclaw-wa-status.json";
14
  const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN;
15
- const SYNC_INTERVAL = process.env.SYNC_INTERVAL || "180";
16
- const APP_BASE = "/app";
 
 
 
 
 
17
  const SYNC_STATUS_FILE = "/tmp/sync-status.json";
18
- const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
19
- "/tmp/huggingclaw-cloudflare-keepalive-status.json";
20
 
21
- function parseRequestUrl(url) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  try {
23
- return new URL(url, "http://localhost");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  } catch {
25
- return new URL("http://localhost/");
26
  }
27
  }
 
 
 
 
 
 
 
 
28
 
29
  function getSyncStatus() {
30
  try {
31
- if (fs.existsSync(SYNC_STATUS_FILE)) {
32
  return JSON.parse(fs.readFileSync(SYNC_STATUS_FILE, "utf8"));
33
- }
34
  } catch {}
35
- if (HF_BACKUP_ENABLED) {
36
- return {
37
- status: "configured",
38
- message: `Backup is enabled. Waiting for sync window (${SYNC_INTERVAL}s).`,
39
- };
40
- }
41
  return { status: "unknown", message: "No sync data yet" };
42
  }
43
 
44
  function readGuardianStatus() {
45
- if (!WHATSAPP_ENABLED) {
46
- return { configured: false, connected: false, pairing: false };
47
- }
48
  try {
49
  if (fs.existsSync(WHATSAPP_STATUS_FILE)) {
50
- const parsed = JSON.parse(fs.readFileSync(WHATSAPP_STATUS_FILE, "utf8"));
51
- return {
52
- configured: parsed.configured !== false,
53
- connected: parsed.connected === true,
54
- pairing: parsed.pairing === true,
55
- };
56
  }
57
  } catch {}
58
  return { configured: true, connected: false, pairing: false };
@@ -60,71 +128,42 @@ function readGuardianStatus() {
60
 
61
  function getKeepaliveStatus() {
62
  try {
63
- if (fs.existsSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE)) {
64
- return JSON.parse(
65
- fs.readFileSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE, "utf8"),
66
- );
67
- }
68
  } catch {}
69
  return null;
70
  }
71
 
72
- function probeGatewayHealth(timeoutMs = 1500) {
73
  return new Promise((resolve) => {
74
- const request = http.get(
75
- {
76
- hostname: GATEWAY_HOST,
77
- port: GATEWAY_PORT,
78
- path: "/health",
79
- timeout: timeoutMs,
80
- },
81
- (response) => {
82
- response.resume();
83
- resolve(response.statusCode >= 200 && response.statusCode < 400);
84
- },
85
- );
86
- request.on("timeout", () => {
87
- request.destroy();
88
- resolve(false);
89
  });
90
- request.on("error", () => resolve(false));
 
91
  });
92
  }
93
 
94
  function formatUptime(ms) {
95
- const total = Math.floor(ms / 1000);
96
- const days = Math.floor(total / 86400);
97
- const hours = Math.floor((total % 86400) / 3600);
98
- const minutes = Math.floor((total % 3600) / 60);
99
- if (days) return `${days}d ${hours}h ${minutes}m`;
100
- if (hours) return `${hours}h ${minutes}m`;
101
- return `${minutes}m`;
102
  }
103
 
104
- function escapeHtml(value) {
105
- return String(value)
106
- .replace(/&/g, "&amp;")
107
- .replace(/</g, "&lt;")
108
- .replace(/>/g, "&gt;")
109
- .replace(/"/g, "&quot;");
110
  }
111
 
112
- function toneBadge(label, tone = "neutral") {
113
  return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
114
  }
115
 
116
- function renderTile({
117
- title,
118
- value,
119
- detail = "",
120
- tone = "neutral",
121
- meta = "",
122
- }) {
123
  return `<article class="tile ${tone}">
124
- <div class="tile-head">
125
- <span class="tile-title">${escapeHtml(title)}</span>
126
- <span class="tile-dot"></span>
127
- </div>
128
  <div class="tile-value">${value}</div>
129
  ${detail ? `<div class="tile-detail">${detail}</div>` : ""}
130
  ${meta ? `<div class="tile-meta">${meta}</div>` : ""}
@@ -133,276 +172,391 @@ function renderTile({
133
 
134
  function renderDashboard(data) {
135
  const syncStatus = String(data.sync?.status || "unknown");
136
- const syncTone = ["success", "restored", "synced", "configured"].includes(
137
- syncStatus,
138
- )
139
- ? "ok"
140
- : syncStatus === "disabled"
141
- ? "warn"
142
- : "neutral";
143
- const backupDetail = data.sync?.message
144
- ? escapeHtml(data.sync.message)
145
- : "No status yet";
146
-
147
- const keepaliveConfigured = data.keepalive?.configured === true;
148
- const keepaliveStatus = String(
149
- data.keepalive?.status ||
150
- (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"),
151
- );
152
- const keepAliveTone = keepaliveConfigured
153
- ? "ok"
154
- : process.env.CLOUDFLARE_WORKERS_TOKEN
155
- ? "warn"
156
- : "neutral";
157
- const keepAliveDetail = keepaliveConfigured
158
- ? `Pinging <code>${escapeHtml(data.keepalive.targetUrl || "/health")}</code>`
159
- : process.env.CLOUDFLARE_WORKERS_TOKEN
160
- ? "Worker pending or failed"
161
- : "Not configured";
162
 
163
  const tiles = [
164
- renderTile({
165
- title: "Gateway",
166
- value: toneBadge(
167
- data.gatewayReady ? "Online" : "Offline",
168
- data.gatewayReady ? "ok" : "off",
169
- ),
170
- detail: `Internal Port ${GATEWAY_PORT}`,
171
- tone: data.gatewayReady ? "ok" : "off",
172
- }),
173
- renderTile({
174
- title: "Model",
175
- value: `<code>${escapeHtml(LLM_MODEL)}</code>`,
176
- detail: `Primary LLM Configured`,
177
- tone: "neutral",
178
- }),
179
- renderTile({
180
- title: "Runtime",
181
- value: escapeHtml(data.uptimeHuman),
182
- detail: `Public Port ${PORT}`,
183
- tone: "neutral",
184
- }),
185
- renderTile({
186
- title: "Telegram",
187
- value: toneBadge(
188
- TELEGRAM_ENABLED ? "Enabled" : "Disabled",
189
- TELEGRAM_ENABLED ? "ok" : "neutral",
190
- ),
191
- detail: TELEGRAM_ENABLED ? "Bot Channel active" : "Not configured",
192
- tone: TELEGRAM_ENABLED ? "ok" : "neutral",
193
- }),
194
- renderTile({
195
- title: "Backup",
196
- value: toneBadge(syncStatus.toUpperCase(), syncTone),
197
- detail: backupDetail,
198
- tone: syncTone,
199
- meta: data.sync?.timestamp
200
- ? `<span class="local-time" data-iso="${data.sync.timestamp}"></span>`
201
- : "",
202
- }),
203
- renderTile({
204
- title: "Keep Awake",
205
- value: toneBadge(
206
- keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(),
207
- keepAliveTone,
208
- ),
209
- detail: keepAliveDetail,
210
- tone: keepAliveTone,
211
- }),
212
- ].join("");
213
-
214
- return `<!doctype html>
215
- <html lang="en">
216
- <head>
217
- <meta charset="utf-8" />
218
- <meta name="viewport" content="width=device-width, initial-scale=1" />
219
  <title>HuggingClaw</title>
220
  <style>
221
- :root { color-scheme: dark; --bg:#08080f; --panel:#12111b; --line:#26243a; --text:#f6f4ff; --muted:#7f7a9e; --soft:#b8b3d7; --good:#22c55e; --warn:#f5c542; --bad:#fb7185; }
222
- * { box-sizing:border-box; }
223
- body { margin:0; min-height:100vh; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); font-size:13px; }
224
- main { width:min(720px, calc(100% - 32px)); margin:0 auto; padding:36px 0 44px; }
225
- header { text-align:center; margin-bottom:22px; }
226
- h1 { margin:0; font-size:1.65rem; line-height:1; letter-spacing:0; }
227
- .subtitle { margin-top:12px; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.14em; font-weight:800; }
228
- .hero-action { display:flex; width:100%; 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; margin:24px 0 20px; transition: opacity 0.15s ease; }
229
- .hero-action:hover { opacity: 0.9; }
230
- .overview { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; margin-bottom:10px; }
231
- .tile { border:1px solid var(--line); background:var(--panel); border-radius:11px; padding:18px; min-height:124px; display:flex; flex-direction:column; gap:10px; position:relative; }
232
- .tile.ok { border-color:rgba(34,197,94,.22); }
233
- .tile.warn { border-color:rgba(245,197,66,.24); }
234
- .tile.off { border-color:rgba(251,113,133,.28); }
235
- .tile-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
236
- .tile-title { color:var(--muted); font-size:.67rem; letter-spacing:.18em; text-transform:uppercase; font-weight:850; }
237
- .tile-dot { width:7px; height:7px; border-radius:50%; background:var(--line); }
238
- .tile.ok .tile-dot { background:var(--good); }
239
- .tile.warn .tile-dot { background:var(--warn); }
240
- .tile.off .tile-dot { background:var(--bad); }
241
- .tile-value { font-size:1.12rem; font-weight:850; overflow-wrap:anywhere; }
242
- .tile-detail { color:var(--soft); line-height:1.45; font-size:.83rem; }
243
- .tile-meta { color:var(--muted); line-height:1.4; font-size:.75rem; margin-top:auto; overflow-wrap:anywhere; }
244
-
245
- code { background:#232234; border:1px solid #34324c; border-radius:6px; padding:2px 6px; color:var(--text); font-size:.9em; }
246
- .badge { display:inline-flex; align-items:center; width:max-content; border:1px solid var(--line); border-radius:999px; padding:5px 10px; font-size:.72rem; font-weight:850; line-height:1; text-transform:uppercase; }
247
- .badge.ok { color:var(--good); border-color:rgba(34,197,94,.34); background:rgba(34,197,94,.11); }
248
- .badge.warn { color:var(--warn); border-color:rgba(245,197,66,.34); background:rgba(245,197,66,.11); }
249
- .badge.off { color:var(--bad); border-color:rgba(251,113,133,.34); background:rgba(251,113,133,.11); }
250
- .badge.neutral { color:var(--soft); }
251
- footer { color:var(--muted); text-align:center; font-size:.74rem; margin-top:18px; }
252
- footer .live { color:var(--good); }
253
- @media (max-width: 700px) { .overview { grid-template-columns:1fr; } main { width:min(100% - 22px, 720px); padding-top:28px; } }
254
- </style>
255
- </head>
256
- <body>
257
- <main>
258
- <header>
259
- <h1>🦞 HuggingClaw</h1>
260
- <div class="subtitle">OpenClaw Gateway Dashboard</div>
261
- </header>
262
- <a class="hero-action" href="${APP_BASE}/" target="_blank" rel="noopener noreferrer">Open Control UI -></a>
263
- <section class="overview">
264
- ${tiles}
265
- </section>
266
- <footer>Built by <a href="https://github.com/somratpro" target="_blank" rel="noopener noreferrer" style="color: var(--accent); text-decoration: none;">@somratpro</a></footer>
267
  </main>
268
  <script>
269
- document.querySelectorAll('.local-time').forEach(el => {
270
- const date = new Date(el.getAttribute('data-iso'));
271
- if (!isNaN(date)) {
272
- el.textContent = 'At ' + date.toLocaleTimeString();
273
- }
274
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  </script>
276
- </body>
277
- </html>`;
278
  }
279
 
280
- const server = http.createServer(async (req, res) => {
281
- const url = parseRequestUrl(req.url);
282
- const pathname = url.pathname;
 
 
 
 
283
 
284
- // 1. Dashboard Routes
285
- if (pathname === "/health") {
286
- const gatewayReady = await probeGatewayHealth();
287
- res.writeHead(gatewayReady ? 200 : 503, {
288
- "Content-Type": "application/json",
289
- });
290
- return res.end(
291
- JSON.stringify({
292
- status: gatewayReady ? "ok" : "degraded",
293
- gatewayReady,
294
- uptime: formatUptime(Date.now() - startTime),
295
- sync: getSyncStatus(),
296
- keepalive: getKeepaliveStatus(),
297
- }),
298
- );
299
  }
 
 
300
 
301
- if (pathname === "/status") {
302
- const gatewayReady = await probeGatewayHealth();
303
- res.writeHead(200, { "Content-Type": "application/json" });
304
- return res.end(
305
- JSON.stringify({
306
- model: LLM_MODEL,
307
- uptime: formatUptime(Date.now() - startTime),
308
- gatewayReady,
309
- sync: getSyncStatus(),
310
- whatsapp: readGuardianStatus(),
311
- keepalive: getKeepaliveStatus(),
312
- }),
313
- );
 
 
 
 
 
 
 
 
 
 
 
314
  }
315
 
316
- if (pathname === "/" || pathname === "/dashboard") {
317
- const gatewayReady = await probeGatewayHealth();
318
- res.writeHead(200, { "Content-Type": "text/html" });
319
- return res.end(
320
- renderDashboard({
321
- uptimeHuman: formatUptime(Date.now() - startTime),
322
- gatewayReady,
323
- sync: getSyncStatus(),
324
- whatsapp: readGuardianStatus(),
325
- keepalive: getKeepaliveStatus(),
326
- }),
327
- );
328
  }
 
329
 
330
- // 2. OpenClaw Proxy Logic
331
- const proxyHeaders = {
 
332
  ...req.headers,
333
- host: `${GATEWAY_HOST}:${GATEWAY_PORT}`,
334
  "x-forwarded-for": req.socket.remoteAddress,
335
  "x-forwarded-host": req.headers.host,
336
  "x-forwarded-proto": "https",
 
337
  };
338
 
339
- const proxyReq = http.request(
340
- {
341
- hostname: GATEWAY_HOST,
342
- port: GATEWAY_PORT,
343
- path: pathname + url.search,
344
- method: req.method,
345
- headers: proxyHeaders,
346
- },
347
- (proxyRes) => {
348
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
349
- proxyRes.pipe(res);
350
- proxyRes.on("error", (err) => {
351
- console.error("proxyRes error:", err);
352
- res.end();
353
- });
354
- },
355
- );
356
 
357
- req.on("error", (err) => {
358
- console.error("req error:", err);
359
- proxyReq.destroy();
360
- });
 
 
361
 
362
- res.on("error", (err) => {
363
- console.error("res error:", err);
364
- proxyReq.destroy();
365
- });
366
 
367
- proxyReq.on("error", (err) => {
368
- console.error("proxyReq error:", err);
369
- if (!res.headersSent) {
370
- res.writeHead(503, { "Content-Type": "application/json" });
371
- res.end(
372
- JSON.stringify({
373
- status: "starting",
374
- message: "Gateway is initializing... or connection failed",
375
- }),
376
- );
377
- } else {
378
- res.end();
 
 
 
 
 
 
 
 
 
379
  }
380
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
 
382
- req.pipe(proxyReq);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  });
384
 
 
385
  server.on("upgrade", (req, socket, head) => {
386
- const url = parseRequestUrl(req.url);
387
- const proxyPath = url.pathname;
388
- const proxySocket = net.connect(GATEWAY_PORT, GATEWAY_HOST, () => {
389
- proxySocket.write(
390
- `${req.method} ${proxyPath}${url.search} HTTP/${req.httpVersion}\r\n`,
391
- );
 
 
 
 
 
 
 
 
392
  for (let i = 0; i < req.rawHeaders.length; i += 2) {
393
- proxySocket.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`);
 
 
 
394
  }
395
- proxySocket.write("\r\n");
396
- if (head && head.length) proxySocket.write(head);
397
- proxySocket.pipe(socket).pipe(proxySocket);
398
  });
399
- proxySocket.on("error", () => socket.destroy());
 
 
 
400
  });
401
 
402
  server.timeout = 0;
403
  server.keepAliveTimeout = 65000;
 
404
  server.listen(PORT, "0.0.0.0", () =>
405
- console.log(
406
- `🦞 HuggingClaw Dashboard on ${PORT} -> Gateway on ${GATEWAY_PORT}`,
407
- ),
408
  );
 
1
+ // Single public entrypoint for HF Spaces: dashboard + reverse proxy to OpenClaw + JupyterLab.
2
  const http = require("http");
3
+ const https = require("https");
4
  const fs = require("fs");
5
  const net = require("net");
6
 
7
+ function isTrue(value) {
8
+ return /^(true|1|yes|on)$/i.test(String(value || "").trim());
9
+ }
10
+ function normalizeBase(value, fallback) {
11
+ const raw = String(value || fallback || "").trim() || fallback;
12
+ if (!raw) return fallback;
13
+ const base = raw.startsWith("/") ? raw : `/${raw}`;
14
+ return base.replace(/\/+$/, "") || fallback;
15
+ }
16
+
17
+ const PORT = Number.parseInt(process.env.PORT || "7861", 10);
18
+ const GATEWAY_PORT = Number.parseInt(process.env.GATEWAY_PORT || "7860", 10);
19
  const GATEWAY_HOST = "127.0.0.1";
20
+ const JUPYTER_PORT = Number.parseInt(process.env.JUPYTER_PORT || "8888", 10);
21
+ const JUPYTER_HOST = "127.0.0.1";
22
+ const JUPYTER_BASE = normalizeBase(process.env.JUPYTER_BASE, "/terminal");
23
+ const DEV_MODE_ENABLED = isTrue(process.env.DEV_MODE);
24
+ const JUPYTER_ENABLED = /^(true|1|yes|on)$/i.test(
25
+ process.env.HUGGINGCLAW_JUPYTER_ENABLED || (DEV_MODE_ENABLED ? "true" : "false")
26
+ );
27
  const startTime = Date.now();
28
  const LLM_MODEL = process.env.LLM_MODEL || "Not Set";
29
  const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN;
30
+ const WHATSAPP_ENABLED = isTrue(process.env.WHATSAPP_ENABLED);
31
  const WHATSAPP_STATUS_FILE = "/tmp/huggingclaw-wa-status.json";
32
  const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN;
33
+ const SYNC_INTERVAL = (process.env.SYNC_INTERVAL || "180").trim() || "180";
34
+ const BACKUP_DATASET_NAME = (process.env.BACKUP_DATASET_NAME || process.env.BACKUP_DATASET || "huggingclaw-backup").trim() || "huggingclaw-backup";
35
+ const DEVDATA_DATASET_NAME = (process.env.DEVDATA_DATASET_NAME || "huggingclaw-devdata").trim() || "huggingclaw-devdata";
36
+ const DEVDATA_SYNC_INTERVAL = (process.env.DEVDATA_SYNC_INTERVAL || "180").trim() || "180";
37
+ const DEVDATA_SEPARATE_DATASET = DEVDATA_DATASET_NAME !== BACKUP_DATASET_NAME;
38
+ const DEVDATA_ENABLED = JUPYTER_ENABLED && HF_BACKUP_ENABLED && DEVDATA_SEPARATE_DATASET && !/^(off|false|0|no)$/i.test((process.env.DEVDATA || "on").trim());
39
+ const APP_BASE = normalizeBase(process.env.APP_BASE, "/app");
40
  const SYNC_STATUS_FILE = "/tmp/sync-status.json";
 
 
41
 
42
+ // ── Private Space redirect support ──
43
+ // HF automatically sets SPACE_ID as "username/spacename" in every Space container.
44
+ const SPACE_ID = (process.env.SPACE_ID || "").trim();
45
+ function deriveHfSpaceUrl() {
46
+ if (SPACE_ID) return `https://huggingface.co/spaces/${SPACE_ID}`;
47
+ const host = (process.env.SPACE_HOST || "").replace(/\.hf\.space$/i, "");
48
+ const author = (process.env.SPACE_AUTHOR_NAME || "").trim().toLowerCase();
49
+ if (author && host.toLowerCase().startsWith(author + "-")) {
50
+ const spaceName = host.slice(author.length + 1);
51
+ return `https://huggingface.co/spaces/${process.env.SPACE_AUTHOR_NAME}/${spaceName}`;
52
+ }
53
+ return "";
54
+ }
55
+ const HF_SPACE_URL = deriveHfSpaceUrl();
56
+
57
+ // Auto-detect space privacy via HF API at startup.
58
+ // Caches result so every request doesn't hit the API.
59
+ let SPACE_IS_PRIVATE = false;
60
+ async function detectSpacePrivacy() {
61
+ if (!SPACE_ID) return;
62
  try {
63
+ const token = (process.env.HF_TOKEN || "").trim();
64
+ const reqOptions = {
65
+ hostname: "huggingface.co",
66
+ path: `/api/spaces/${SPACE_ID}`,
67
+ method: "GET",
68
+ headers: Object.assign(
69
+ { "User-Agent": "HuggingClaw/health-server" },
70
+ token ? { Authorization: `Bearer ${token}` } : {}
71
+ ),
72
+ };
73
+ await new Promise((resolve) => {
74
+ const r = https.request(reqOptions, (res) => {
75
+ let body = "";
76
+ res.on("data", (chunk) => { body += chunk; });
77
+ res.on("end", () => {
78
+ try {
79
+ if (res.statusCode === 200) {
80
+ const data = JSON.parse(body);
81
+ SPACE_IS_PRIVATE = data.private === true;
82
+ } else if (res.statusCode === 404 && !token) {
83
+ // 404 with no token usually means private space
84
+ SPACE_IS_PRIVATE = true;
85
+ }
86
+ } catch {}
87
+ resolve();
88
+ });
89
+ });
90
+ r.on("error", resolve);
91
+ r.setTimeout(5000, () => { r.destroy(); resolve(); });
92
+ r.end();
93
+ });
94
+ console.log(`[health-server] Space privacy detected: ${SPACE_IS_PRIVATE ? "private" : "public"}`);
95
  } catch {
96
+ // Network error — default to false (safe)
97
  }
98
  }
99
+ detectSpacePrivacy();
100
+ const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
101
+ "/tmp/huggingclaw-cloudflare-keepalive-status.json";
102
+
103
+ function parseRequestUrl(url) {
104
+ try { return new URL(url, "http://localhost"); }
105
+ catch { return new URL("http://localhost/"); }
106
+ }
107
 
108
  function getSyncStatus() {
109
  try {
110
+ if (fs.existsSync(SYNC_STATUS_FILE))
111
  return JSON.parse(fs.readFileSync(SYNC_STATUS_FILE, "utf8"));
 
112
  } catch {}
113
+ if (HF_BACKUP_ENABLED)
114
+ return { status: "configured", message: `Backup enabled. Waiting for sync window (${SYNC_INTERVAL}s).` };
 
 
 
 
115
  return { status: "unknown", message: "No sync data yet" };
116
  }
117
 
118
  function readGuardianStatus() {
119
+ if (!WHATSAPP_ENABLED) return { configured: false, connected: false, pairing: false };
 
 
120
  try {
121
  if (fs.existsSync(WHATSAPP_STATUS_FILE)) {
122
+ const p = JSON.parse(fs.readFileSync(WHATSAPP_STATUS_FILE, "utf8"));
123
+ return { configured: p.configured !== false, connected: p.connected === true, pairing: p.pairing === true };
 
 
 
 
124
  }
125
  } catch {}
126
  return { configured: true, connected: false, pairing: false };
 
128
 
129
  function getKeepaliveStatus() {
130
  try {
131
+ if (fs.existsSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE))
132
+ return JSON.parse(fs.readFileSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE, "utf8"));
 
 
 
133
  } catch {}
134
  return null;
135
  }
136
 
137
+ function probePort(host, port, path, timeoutMs = 1500) {
138
  return new Promise((resolve) => {
139
+ const req = http.get({ hostname: host, port, path, timeout: timeoutMs }, (res) => {
140
+ res.resume();
141
+ resolve(res.statusCode >= 200 && res.statusCode < 400);
 
 
 
 
 
 
 
 
 
 
 
 
142
  });
143
+ req.on("timeout", () => { req.destroy(); resolve(false); });
144
+ req.on("error", () => resolve(false));
145
  });
146
  }
147
 
148
  function formatUptime(ms) {
149
+ const t = Math.floor(ms / 1000);
150
+ const d = Math.floor(t / 86400), h = Math.floor((t % 86400) / 3600), m = Math.floor((t % 3600) / 60);
151
+ if (d) return `${d}d ${h}h ${m}m`;
152
+ if (h) return `${h}h ${m}m`;
153
+ return `${m}m`;
 
 
154
  }
155
 
156
+ function escapeHtml(v) {
157
+ return String(v).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
 
 
 
 
158
  }
159
 
160
+ function badge(label, tone = "neutral") {
161
  return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
162
  }
163
 
164
+ function tile({ title, value, detail = "", tone = "neutral", meta = "" }) {
 
 
 
 
 
 
165
  return `<article class="tile ${tone}">
166
+ <div class="tile-head"><span class="tile-title">${escapeHtml(title)}</span><span class="tile-dot"></span></div>
 
 
 
167
  <div class="tile-value">${value}</div>
168
  ${detail ? `<div class="tile-detail">${detail}</div>` : ""}
169
  ${meta ? `<div class="tile-meta">${meta}</div>` : ""}
 
172
 
173
  function renderDashboard(data) {
174
  const syncStatus = String(data.sync?.status || "unknown");
175
+ const syncTone = ["success","restored","synced","configured"].includes(syncStatus) ? "ok" : syncStatus === "disabled" ? "warn" : "neutral";
176
+ const kaConf = data.keepalive?.configured === true;
177
+ const kaStatus = String(data.keepalive?.status || (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"));
178
+ const kaTone = kaConf ? "ok" : process.env.CLOUDFLARE_WORKERS_TOKEN ? "warn" : "neutral";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
  const tiles = [
181
+ tile({ title: "Gateway", value: badge(data.gatewayReady ? "Online" : "Offline", data.gatewayReady ? "ok" : "off"), detail: `OpenClaw on internal port ${GATEWAY_PORT}`, tone: data.gatewayReady ? "ok" : "off" }),
182
+ tile({ title: "Model", value: `<code>${escapeHtml(LLM_MODEL)}</code>`, detail: "Primary LLM configured", tone: "neutral" }),
183
+ tile({ title: "Runtime", value: escapeHtml(data.uptimeHuman), detail: `Public port ${PORT}`, tone: "neutral" }),
184
+ tile({ title: "Telegram", value: badge(TELEGRAM_ENABLED ? "Enabled" : "Disabled", TELEGRAM_ENABLED ? "ok" : "neutral"), detail: TELEGRAM_ENABLED ? "Bot channel active" : "Not configured", tone: TELEGRAM_ENABLED ? "ok" : "neutral" }),
185
+ ];
186
+
187
+
188
+ tiles.push(
189
+ tile({ title: "Backup", value: badge(syncStatus.toUpperCase(), syncTone), detail: escapeHtml(data.sync?.message || "No status yet"), tone: syncTone, meta: data.sync?.timestamp ? `<span class="local-time" data-iso="${data.sync.timestamp}"></span>` : "" }),
190
+ tile({ title: "Keep Awake", value: badge(kaConf ? "CF Cron" : kaStatus.toUpperCase(), kaTone), detail: kaConf ? `Pinging <code>${escapeHtml(data.keepalive?.targetUrl || "/health")}</code>` : process.env.CLOUDFLARE_WORKERS_TOKEN ? "Worker pending or failed" : "Not configured", tone: kaTone }),
191
+ );
192
+
193
+ if (JUPYTER_ENABLED) {
194
+ 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" }));
195
+ tiles.push(tile({
196
+ title: "DevData",
197
+ value: badge(DEVDATA_ENABLED ? "Enabled" : "Disabled", DEVDATA_ENABLED ? "ok" : "neutral"),
198
+ 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",
199
+ tone: DEVDATA_ENABLED ? "ok" : "neutral",
200
+ meta: `Sync interval ${escapeHtml(DEVDATA_SYNC_INTERVAL)}s`,
201
+ }));
202
+ }
203
+
204
+ const tilesHtml = tiles.join("");
205
+
206
+ return `<!doctype html><html lang="en"><head>
207
+ <meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  <title>HuggingClaw</title>
209
  <style>
210
+ :root{color-scheme:dark;--bg:#08080f;--panel:#12111b;--line:#26243a;--text:#f6f4ff;--muted:#7f7a9e;--soft:#b8b3d7;--good:#22c55e;--warn:#f5c542;--bad:#fb7185}
211
+ *{box-sizing:border-box}body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);font-size:13px}
212
+ main{width:min(720px,calc(100% - 32px));margin:0 auto;padding:36px 0 44px}
213
+ header{text-align:center;margin-bottom:22px}h1{margin:0;font-size:1.65rem;line-height:1}
214
+ .subtitle{margin-top:12px;color:var(--muted);font-size:.72rem;text-transform:uppercase;letter-spacing:.14em;font-weight:800}
215
+ .btn-row{display:flex;gap:12px;margin:24px 0 20px}
216
+ .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}
217
+ .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}
218
+ .overview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:10px}
219
+ .tile{border:1px solid var(--line);background:var(--panel);border-radius:11px;padding:18px;min-height:124px;display:flex;flex-direction:column;gap:10px}
220
+ .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)}
221
+ .tile-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
222
+ .tile-title{color:var(--muted);font-size:.67rem;letter-spacing:.18em;text-transform:uppercase;font-weight:850}
223
+ .tile-dot{width:7px;height:7px;border-radius:50%;background:var(--line)}
224
+ .tile.ok .tile-dot{background:var(--good)}.tile.warn .tile-dot{background:var(--warn)}.tile.off .tile-dot{background:var(--bad)}
225
+ .tile-value{font-size:1.12rem;font-weight:850;overflow-wrap:anywhere}.tile-detail{color:var(--soft);line-height:1.45;font-size:.83rem}
226
+ .tile-meta{color:var(--muted);line-height:1.4;font-size:.75rem;margin-top:auto;overflow-wrap:anywhere}
227
+ code{background:#232234;border:1px solid #34324c;border-radius:6px;padding:2px 6px;color:var(--text);font-size:.9em}
228
+ .badge{display:inline-flex;align-items:center;width:max-content;border:1px solid var(--line);border-radius:999px;padding:5px 10px;font-size:.72rem;font-weight:850;line-height:1;text-transform:uppercase}
229
+ .badge.ok{color:var(--good);border-color:rgba(34,197,94,.34);background:rgba(34,197,94,.11)}
230
+ .badge.warn{color:var(--warn);border-color:rgba(245,197,66,.34);background:rgba(245,197,66,.11)}
231
+ .badge.off{color:var(--bad);border-color:rgba(251,113,133,.34);background:rgba(251,113,133,.11)}
232
+ .badge.neutral{color:var(--soft)}
233
+ footer{color:var(--muted);text-align:center;font-size:.74rem;margin-top:18px}
234
+ @media(max-width:700px){.overview{grid-template-columns:1fr}main{width:min(100% - 22px,720px);padding-top:28px}.btn-row{flex-direction:column}}
235
+ </style></head><body><main>
236
+ <header><h1>🦞 HuggingClaw</h1><div class="subtitle">OpenClaw Gateway</div></header>
237
+ <div class="btn-row">
238
+ <a class="hero-action" data-space-link="app" href="${APP_BASE}/">Open Control UI →</a>
239
+ ${JUPYTER_ENABLED ? `<a class="hero-action terminal" data-space-link="terminal" href="${JUPYTER_BASE}/">💻 Open Terminal →</a>` : ""}
240
+ <a class="hero-action env" data-space-link="env-builder" href="/env-builder">⚙️ Env Builder →</a>
241
+ </div>
242
+ <section class="overview">${tilesHtml}</section>
243
+ <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 open via <code>.hf.space</code> directly. Private Spaces require the <a href="${HF_SPACE_URL || "#"}" target="_blank" rel="noopener noreferrer" style="color:inherit">Hugging Face App tab</a> for the authenticated session${HF_SPACE_URL ? ` — or share <code>huggingface.co/spaces/${SPACE_ID}</code>` : ""}.</span></footer>
 
 
 
 
 
 
 
 
 
 
 
 
244
  </main>
245
  <script>
246
+ document.querySelectorAll('.local-time').forEach(el=>{const d=new Date(el.getAttribute('data-iso'));if(!isNaN(d))el.textContent='At '+d.toLocaleTimeString()});
247
+ const inEmbeddedApp = (() => { try { return window.top !== window.self; } catch { return true; } })();
248
+ const isDirectHfSpaceHost = /\.hf\.space$/i.test(window.location.hostname);
249
+ const HF_SPACE_URL = ${JSON.stringify(HF_SPACE_URL)};
250
+ const SPACE_IS_PRIVATE = ${JSON.stringify(SPACE_IS_PRIVATE)};
251
+ // ── Private Space Guard ──
252
+ // Direct .hf.space access outside the HF App iframe has no valid session cookie
253
+ // for private spaces — HF CDN returns 404 before the request reaches the container.
254
+ // Redirect users to huggingface.co/spaces/... which authenticates them properly.
255
+ if (SPACE_IS_PRIVATE && isDirectHfSpaceHost && !inEmbeddedApp && HF_SPACE_URL) {
256
+ const notice = document.createElement('div');
257
+ notice.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#08080f;color:#f6f4ff;font-family:sans-serif;flex-direction:column;gap:16px;z-index:9999';
258
+ notice.innerHTML = '<span style="font-size:1.1rem">🔒 Private Space &mdash; Redirecting&hellip;</span><a href="' + HF_SPACE_URL + '" style="color:#a5b4fc;font-size:.85rem">Click here if not redirected</a>';
259
+ document.body.appendChild(notice);
260
+ setTimeout(() => { window.location.replace(HF_SPACE_URL); }, 300);
261
+ }
262
+ // If inside the HF App iframe, force new-tab navigation so users can break out
263
+ // to the standalone Space host. Also keep direct .hf.space behavior opening new tabs.
264
+ const openInNewTab = inEmbeddedApp || isDirectHfSpaceHost;
265
+ document.querySelectorAll('a[data-space-link]').forEach((a) => {
266
+ if (openInNewTab) {
267
+ a.setAttribute('target', '_blank');
268
+ a.setAttribute('rel', 'noopener noreferrer');
269
+ } else {
270
+ a.removeAttribute('target');
271
+ a.removeAttribute('rel');
272
+ }
273
+ });
274
+ </script>
275
+ </body></html>`;
276
+ }
277
+
278
+ function renderPrivateRedirect(targetUrl) {
279
+ const safeUrl = escapeHtml(targetUrl);
280
+ return `<!doctype html><html lang="en"><head>
281
+ <meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
282
+ <meta http-equiv="refresh" content="3;url=${safeUrl}"/>
283
+ <title>HuggingClaw — Private Space</title>
284
+ <style>
285
+ :root{color-scheme:dark}
286
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
287
+ font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;
288
+ background:#08080f;color:#f6f4ff;text-align:center;padding:24px}
289
+ .card{border:1px solid #26243a;background:#12111b;border-radius:14px;padding:36px 32px;max-width:440px}
290
+ h1{margin:0 0 12px;font-size:1.5rem}
291
+ p{color:#b8b3d7;line-height:1.6;margin:0 0 24px}
292
+ .btn{display:inline-flex;align-items:center;justify-content:center;
293
+ background:#fff;color:#000;font-weight:850;font-size:.95rem;
294
+ border-radius:8px;padding:12px 28px;text-decoration:none;transition:opacity .15s}
295
+ .btn:hover{opacity:.85}
296
+ .sub{color:#7f7a9e;font-size:.78rem;margin-top:16px}
297
+ </style></head><body>
298
+ <div class="card">
299
+ <h1>🔒 Private Space</h1>
300
+ <p>This HuggingFace Space is private. You need to be logged in to <strong>huggingface.co</strong> to access it.<br><br>Redirecting you now&hellip;</p>
301
+ <a class="btn" href="${safeUrl}">Open on Hugging Face →</a>
302
+ <div class="sub">Redirecting in 3 seconds&hellip;</div>
303
+ </div>
304
+ <script>
305
+ // Immediate redirect if JS available — don't wait for meta refresh
306
+ setTimeout(() => { window.location.replace(${JSON.stringify(targetUrl)}); }, 100);
307
  </script>
308
+ </body></html>`;
 
309
  }
310
 
311
+ function renderEnvBuilder() {
312
+ try {
313
+ return fs.readFileSync(require("path").join(__dirname, "env-builder.html"), "utf8");
314
+ } catch (exc) {
315
+ return `<!doctype html><title>Env Builder unavailable</title><pre>${escapeHtml(exc.message)}</pre>`;
316
+ }
317
+ }
318
 
319
+ // ── Generic proxy ──
320
+ function proxiedPath(url, { stripPrefix = "" } = {}) {
321
+ if (!stripPrefix) return url.pathname + url.search;
322
+ if (url.pathname === stripPrefix) return "/" + url.search;
323
+ if (url.pathname.startsWith(stripPrefix + "/")) {
324
+ return url.pathname.slice(stripPrefix.length) + url.search;
 
 
 
 
 
 
 
 
 
325
  }
326
+ return url.pathname + url.search;
327
+ }
328
 
329
+ function rewriteProxyHeaders(headers, { publicPrefix = "", targetHost = "", targetPort = "" } = {}) {
330
+ const next = { ...headers };
331
+
332
+ // Keep browser redirects inside the public HF Space path. Backends may emit
333
+ // root-relative redirects ("/login") or absolute redirects pointing at their
334
+ // internal listener ("http://127.0.0.1:8888/..."). Both break from a browser
335
+ // if we do not normalize them back to the public mount path.
336
+ if (publicPrefix && typeof next.location === "string") {
337
+ try {
338
+ const internalOrigins = new Set([
339
+ "http://huggingclaw.local",
340
+ `http://${targetHost}:${targetPort}`,
341
+ `http://localhost:${targetPort}`,
342
+ `http://127.0.0.1:${targetPort}`,
343
+ ]);
344
+ const location = new URL(next.location, "http://huggingclaw.local");
345
+ if (internalOrigins.has(location.origin)) {
346
+ let path = location.pathname;
347
+ if (path !== publicPrefix && !path.startsWith(publicPrefix + "/")) {
348
+ path = publicPrefix + (path.startsWith("/") ? path : `/${path}`);
349
+ }
350
+ next.location = path + location.search + location.hash;
351
+ }
352
+ } catch {}
353
  }
354
 
355
+ return next;
356
+ }
357
+
358
+ function sendServiceUnavailable(res) {
359
+ if (!res.headersSent) {
360
+ res.writeHead(503, { "Content-Type": "application/json" });
361
+ res.end(JSON.stringify({ status: "starting", message: "Service is initializing… please wait." }));
362
+ } else {
363
+ res.end();
 
 
 
364
  }
365
+ }
366
 
367
+ function proxyHTTP(req, res, targetHost, targetPort, options = {}) {
368
+ const url = parseRequestUrl(req.url);
369
+ const headers = {
370
  ...req.headers,
371
+ host: `${targetHost}:${targetPort}`,
372
  "x-forwarded-for": req.socket.remoteAddress,
373
  "x-forwarded-host": req.headers.host,
374
  "x-forwarded-proto": "https",
375
+ "x-forwarded-prefix": options.publicPrefix || "",
376
  };
377
 
378
+ const canReplayRequest = req.method === "GET" || req.method === "HEAD";
379
+ const proxyOnce = (path, retryOn404) => {
380
+ const pr = http.request({ hostname: targetHost, port: targetPort, path, method: req.method, headers }, (pres) => {
381
+ if (canReplayRequest && retryOn404 && pres.statusCode === 404 && options.stripPrefix) {
382
+ pres.resume();
383
+ return proxyOnce(proxiedPath(url, { stripPrefix: options.stripPrefix }), false);
384
+ }
385
+ res.writeHead(pres.statusCode, rewriteProxyHeaders(pres.headers, { ...options, targetHost, targetPort }));
386
+ pres.pipe(res);
387
+ pres.on("error", () => res.end());
388
+ });
389
+ req.on("error", () => pr.destroy());
390
+ res.on("error", () => pr.destroy());
391
+ pr.on("error", () => sendServiceUnavailable(res));
392
+ req.pipe(pr);
393
+ };
 
394
 
395
+ // First try the public path as-is because OpenClaw and JupyterLab are both
396
+ // configured with base paths. If a backend still returns 404, retry with the
397
+ // mount prefix stripped; that covers images built before the base-path config
398
+ // took effect and avoids the common HF Spaces "404 at /app or /terminal" trap.
399
+ proxyOnce(url.pathname + url.search, !!options.retryWithoutPrefixOn404);
400
+ }
401
 
402
+ // ── HTTP server ──
403
+ const server = http.createServer(async (req, res) => {
404
+ const { pathname } = parseRequestUrl(req.url);
 
405
 
406
+ if (pathname === "/health") {
407
+ const gatewayReady = await probePort(GATEWAY_HOST, GATEWAY_PORT, "/health");
408
+ res.writeHead(gatewayReady ? 200 : 503, { "Content-Type": "application/json" });
409
+ return res.end(JSON.stringify({ status: gatewayReady ? "ok" : "degraded", gatewayReady, uptime: formatUptime(Date.now() - startTime), sync: getSyncStatus(), keepalive: getKeepaliveStatus() }));
410
+ }
411
+
412
+ if (pathname === "/status") {
413
+ const [gatewayReady, jupyterReady] = await Promise.all([
414
+ probePort(GATEWAY_HOST, GATEWAY_PORT, "/health"),
415
+ JUPYTER_ENABLED ? probePort(JUPYTER_HOST, JUPYTER_PORT, `${JUPYTER_BASE}/login`) : Promise.resolve(false),
416
+ ]);
417
+ res.writeHead(200, { "Content-Type": "application/json" });
418
+ return res.end(JSON.stringify({ model: LLM_MODEL, uptime: formatUptime(Date.now() - startTime), gatewayReady, jupyterReady, sync: getSyncStatus(), whatsapp: readGuardianStatus(), keepalive: getKeepaliveStatus() }));
419
+ }
420
+
421
+ // Private space redirect — send users to the authenticated HF Spaces page.
422
+ // Works for both direct .hf.space links AND programmatic shares.
423
+ if (pathname === "/hf-redirect" || pathname === "/hf-redirect/") {
424
+ if (HF_SPACE_URL) {
425
+ res.writeHead(302, { Location: HF_SPACE_URL, "Cache-Control": "no-store" });
426
+ return res.end();
427
  }
428
+ res.writeHead(404, { "Content-Type": "text/plain" });
429
+ return res.end("SPACE_ID not configured.");
430
+ }
431
+
432
+ // ── Private Space Guard (server-side) ──
433
+ // Triggers automatically when SPACE_IS_PRIVATE=true (detected via HF API at startup).
434
+ // Only intercepts browser navigation (Accept: text/html) — API calls, assets,
435
+ // and WebSocket upgrades pass through untouched.
436
+ // /health and /status are always exempt so uptime monitors keep working.
437
+ const isHtmlRequest = (req.headers.accept || "").includes("text/html");
438
+ const isDirectHfSpaceRequest = SPACE_IS_PRIVATE &&
439
+ HF_SPACE_URL &&
440
+ isHtmlRequest &&
441
+ typeof req.headers.host === "string" &&
442
+ req.headers.host.endsWith(".hf.space");
443
 
444
+ if (pathname === "/env-builder" || pathname === "/env-builder/") {
445
+ if (isDirectHfSpaceRequest) {
446
+ res.writeHead(200, { "Content-Type": "text/html" });
447
+ return res.end(renderPrivateRedirect(HF_SPACE_URL));
448
+ }
449
+ res.writeHead(200, { "Content-Type": "text/html" });
450
+ return res.end(renderEnvBuilder());
451
+ }
452
+
453
+ if (pathname === "/env-builder.js") {
454
+ try {
455
+ const js = fs.readFileSync(require("path").join(__dirname, "env-builder.js"), "utf8");
456
+ res.writeHead(200, { "Content-Type": "application/javascript" });
457
+ return res.end(js);
458
+ } catch (exc) {
459
+ res.writeHead(404, { "Content-Type": "text/plain" });
460
+ return res.end(`env-builder.js not found: ${exc.message}`);
461
+ }
462
+ }
463
+
464
+ if (pathname === "/" || pathname === "/dashboard") {
465
+ if (isDirectHfSpaceRequest) {
466
+ res.writeHead(200, { "Content-Type": "text/html" });
467
+ return res.end(renderPrivateRedirect(HF_SPACE_URL));
468
+ }
469
+ const [gatewayReady, jupyterReady] = await Promise.all([
470
+ probePort(GATEWAY_HOST, GATEWAY_PORT, "/health"),
471
+ JUPYTER_ENABLED ? probePort(JUPYTER_HOST, JUPYTER_PORT, `${JUPYTER_BASE}/login`) : Promise.resolve(false),
472
+ ]);
473
+ res.writeHead(200, { "Content-Type": "text/html" });
474
+ return res.end(renderDashboard({ uptimeHuman: formatUptime(Date.now() - startTime), gatewayReady, jupyterReady, sync: getSyncStatus(), whatsapp: readGuardianStatus(), keepalive: getKeepaliveStatus() }));
475
+ }
476
+
477
+ // JupyterLab terminal
478
+ if (pathname === JUPYTER_BASE || pathname.startsWith(JUPYTER_BASE + "/")) {
479
+ if (!JUPYTER_ENABLED) {
480
+ res.writeHead(404, { "Content-Type": "application/json" });
481
+ return res.end(JSON.stringify({ status: "disabled", message: "JupyterLab terminal is disabled. Set DEV_MODE=true to enable /terminal/." }));
482
+ }
483
+ if (isDirectHfSpaceRequest) {
484
+ res.writeHead(200, { "Content-Type": "text/html" });
485
+ return res.end(renderPrivateRedirect(HF_SPACE_URL));
486
+ }
487
+ return proxyHTTP(req, res, JUPYTER_HOST, JUPYTER_PORT, {
488
+ publicPrefix: JUPYTER_BASE,
489
+ // Jupyter is started with --ServerApp.base_url=/terminal/, so keep the
490
+ // /terminal prefix when proxying. Stripping it breaks static/theme URLs.
491
+ stripPrefix: "",
492
+ retryWithoutPrefixOn404: false,
493
+ });
494
+ }
495
+
496
+ // OpenClaw Control UI mounted under /app. Retry without the mount prefix on
497
+ // 404 so deployments keep working across OpenClaw basePath behavior changes.
498
+ if (pathname === APP_BASE || pathname.startsWith(APP_BASE + "/")) {
499
+ if (isDirectHfSpaceRequest) {
500
+ res.writeHead(200, { "Content-Type": "text/html" });
501
+ return res.end(renderPrivateRedirect(HF_SPACE_URL));
502
+ }
503
+ return proxyHTTP(req, res, GATEWAY_HOST, GATEWAY_PORT, {
504
+ publicPrefix: APP_BASE,
505
+ stripPrefix: APP_BASE,
506
+ retryWithoutPrefixOn404: true,
507
+ });
508
+ }
509
+
510
+ // Favicon — serve a minimal inline SVG so browsers don't proxy to the gateway
511
+ if (pathname === "/favicon.ico" || pathname === "/favicon.svg") {
512
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🦞</text></svg>';
513
+ res.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=86400" });
514
+ return res.end(svg);
515
+ }
516
+
517
+ // OpenClaw gateway API/static fallback (everything else)
518
+ if (isDirectHfSpaceRequest) {
519
+ res.writeHead(200, { "Content-Type": "text/html" });
520
+ return res.end(renderPrivateRedirect(HF_SPACE_URL));
521
+ }
522
+ proxyHTTP(req, res, GATEWAY_HOST, GATEWAY_PORT);
523
  });
524
 
525
+ // ── WebSocket upgrade (JupyterLab kernels + terminals need this) ──
526
  server.on("upgrade", (req, socket, head) => {
527
+ const { pathname, search } = parseRequestUrl(req.url);
528
+ const isJupyter = JUPYTER_ENABLED && (pathname === JUPYTER_BASE || pathname.startsWith(JUPYTER_BASE + "/"));
529
+ const isApp = pathname === APP_BASE || pathname.startsWith(APP_BASE + "/");
530
+ const [targetHost, targetPort] = isJupyter ? [JUPYTER_HOST, JUPYTER_PORT] : [GATEWAY_HOST, GATEWAY_PORT];
531
+ const publicPrefix = isJupyter ? JUPYTER_BASE : isApp ? APP_BASE : "";
532
+ const targetPath = pathname + search;
533
+
534
+ const ps = net.connect(targetPort, targetHost, () => {
535
+ ps.write(`${req.method} ${targetPath} HTTP/${req.httpVersion}\r\n`);
536
+ ps.write(`Host: ${targetHost}:${targetPort}\r\n`);
537
+ ps.write(`X-Forwarded-For: ${req.socket.remoteAddress || ""}\r\n`);
538
+ ps.write(`X-Forwarded-Host: ${req.headers.host || ""}\r\n`);
539
+ ps.write("X-Forwarded-Proto: https\r\n");
540
+ if (publicPrefix) ps.write(`X-Forwarded-Prefix: ${publicPrefix}\r\n`);
541
  for (let i = 0; i < req.rawHeaders.length; i += 2) {
542
+ const header = req.rawHeaders[i];
543
+ const lower = header.toLowerCase();
544
+ if (["host", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "x-forwarded-prefix"].includes(lower)) continue;
545
+ ps.write(`${header}: ${req.rawHeaders[i + 1]}\r\n`);
546
  }
547
+ ps.write("\r\n");
548
+ if (head && head.length) ps.write(head);
549
+ ps.pipe(socket).pipe(ps);
550
  });
551
+ ps.on("error", () => socket.destroy());
552
+ ps.on("close", () => socket.destroy());
553
+ socket.on("error", () => ps.destroy());
554
+ socket.on("close", () => ps.destroy());
555
  });
556
 
557
  server.timeout = 0;
558
  server.keepAliveTimeout = 65000;
559
+ server.on("error", (err) => console.error(`[health-server] Server error:`, err));
560
  server.listen(PORT, "0.0.0.0", () =>
561
+ console.log(`🦞 HuggingClaw :${PORT} → Gateway :${GATEWAY_PORT}${JUPYTER_ENABLED ? ` | Terminal :${JUPYTER_PORT} at ${JUPYTER_BASE}/` : " | Terminal disabled"}`),
 
 
562
  );
iframe-fix.cjs CHANGED
@@ -1,5 +1,6 @@
1
  process.on('uncaughtException', function(err) {
2
  if (err.code === 'EPIPE') return;
 
3
  throw err;
4
  });
5
  /**
 
1
  process.on('uncaughtException', function(err) {
2
  if (err.code === 'EPIPE') return;
3
+ if (err.code === 'ECONNRESET' || err.code === 'ENOTCONN' || err.code === 'ERR_STREAM_DESTROYED') return;
4
  throw err;
5
  });
6
  /**
jupyter-devdata-sync.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import os, shutil, socket, sys, 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
+ # BUG FIX #5: Respect max file size so giant files don't stall uploads.
14
+ # Matches the 50 MB ceiling in openclaw-sync.py; override with DEVDATA_MAX_FILE_BYTES.
15
+ MAX_FILE_SIZE_BYTES = int(
16
+ (os.environ.get("DEVDATA_MAX_FILE_BYTES", "").strip() or str(50 * 1024 * 1024))
17
+ )
18
+
19
+ def is_true(value):
20
+ return str(value).strip().lower() in {"1", "true", "yes", "on"}
21
+
22
+ ENABLE = is_true(os.environ.get("DEVDATA", "on"))
23
+
24
+
25
+ def classify_error(exc: Exception) -> str:
26
+ msg = str(exc).lower()
27
+ if isinstance(exc, PermissionError) or "permission denied" in msg:
28
+ return "filesystem-permission"
29
+ if any(k in msg for k in ("connection error", "fetch failed", "timeout", "temporarily unavailable", "network")):
30
+ return "network-provider"
31
+ if "unsafe" in msg or "malware" in msg or "security" in msg:
32
+ return "safety-scan"
33
+ return "general"
34
+
35
+ # BUG FIX #4: ".local/share/Trash" in the original EXCLUDE set was a
36
+ # multi-component path string that was never matched because parts-based
37
+ # lookup compares individual directory names. Added "Trash" as a standalone
38
+ # component so any path with a "Trash" segment (e.g. .local/share/Trash/*)
39
+ # is correctly skipped during snapshot and restore.
40
+ EXCLUDE = {
41
+ ".cache",
42
+ "node_modules",
43
+ ".npm",
44
+ ".yarn",
45
+ "Trash", # BUG FIX #4: covers .local/share/Trash (was ".local/share/Trash" — never matched)
46
+ ".ipynb_checkpoints",
47
+ ".openclaw",
48
+ "app",
49
+ "HuggingClaw",
50
+ "HuggingClaw-Workspace",
51
+ "browser-deps",
52
+ # Exclude Python/system package directories — these contain thousands of files
53
+ # (e.g. .local/lib/python3.11/site-packages/) and must not be synced to the
54
+ # HF Dataset. Syncing them causes 10,000+ file fetches on every restore and
55
+ # can restore a broken jsonschema that crashes JupyterLab on boot.
56
+ ".local",
57
+ "lib",
58
+ "site-packages",
59
+ "__pycache__",
60
+ }
61
+
62
+
63
+ def enabled():
64
+ dev = is_true(os.environ.get("DEV_MODE", ""))
65
+ separate_dataset = DATASET_NAME != BACKUP_DATASET_NAME
66
+ if ENABLE and dev and HF_TOKEN and not separate_dataset:
67
+ print("DevData sync disabled: DEVDATA_DATASET_NAME must be separate from BACKUP_DATASET_NAME.")
68
+ return ENABLE and dev and bool(HF_TOKEN) and separate_dataset
69
+
70
+ def validate_jupyter_paths() -> None:
71
+ # JupyterLab theme/settings live under ~/.jupyter and ~/.local/share/jupyter.
72
+ # If these are not writable, settings can appear to "reset" every restart.
73
+ for required in (JUPYTER_ROOT, Path("/home/node/.jupyter"), Path("/home/node/.local/share/jupyter")):
74
+ try:
75
+ required.mkdir(parents=True, exist_ok=True)
76
+ probe = required / ".devdata-write-check"
77
+ probe.write_text("ok", encoding="utf-8")
78
+ probe.unlink(missing_ok=True)
79
+ except Exception as exc:
80
+ kind = classify_error(exc)
81
+ print(f"DevData warning [{kind}]: {required} is not writable; Jupyter settings may not persist ({exc})")
82
+
83
+ def repo_id(api) -> str:
84
+ ns = HF_USERNAME
85
+ if not ns:
86
+ who = api.whoami()
87
+ ns = who.get("name") or who.get("user") or ""
88
+ if not ns:
89
+ raise RuntimeError("Cannot resolve HF namespace for devdata sync")
90
+ return f"{ns}/{DATASET_NAME}"
91
+
92
+ # Filename patterns that must never be synced to a public/private HF Dataset.
93
+ # These are matched against the *name* of each path component (not the full path),
94
+ # so ".env" matches /home/node/.env and /home/node/subdir/.env alike.
95
+ import fnmatch as _fnmatch
96
+
97
+ SECRET_FILENAME_PATTERNS = {
98
+ ".env", # dotenv files — almost always contain API keys
99
+ ".env.*", # .env.local, .env.production, etc.
100
+ "*secret*", # any file/dir whose name contains "secret"
101
+ "*secrets*",
102
+ "*_secret*",
103
+ "*-secret*",
104
+ "*key*", # private keys, API key files
105
+ "*_key*",
106
+ "*-key*",
107
+ "*token*", # token files
108
+ "*_token*",
109
+ "*-token*",
110
+ "*.pem", # TLS/SSH private keys
111
+ "*.key", # generic key files
112
+ "*.p12", # PKCS#12 bundles
113
+ "*.pfx",
114
+ "credentials", # common credential file names
115
+ "credentials.*",
116
+ ".netrc", # stores plaintext passwords
117
+ ".htpasswd",
118
+ }
119
+
120
+
121
+ def _name_is_secret(name: str) -> bool:
122
+ """Return True if *name* matches any secret-exclusion pattern."""
123
+ name_lower = name.lower()
124
+ return any(_fnmatch.fnmatch(name_lower, pat) for pat in SECRET_FILENAME_PATTERNS)
125
+
126
+
127
+ def should_skip(p: Path):
128
+ # Skip directories/files in the hard-coded exclude set.
129
+ parts = p.parts
130
+ if any(x in parts for x in EXCLUDE):
131
+ return True
132
+ # Skip any component whose name looks like a secret file/dir.
133
+ return any(_name_is_secret(part) for part in parts)
134
+
135
+ def snapshot(src: Path, dst: Path):
136
+ for p in src.rglob("*"):
137
+ rel = p.relative_to(src)
138
+ if should_skip(rel):
139
+ continue
140
+ if p.is_symlink():
141
+ continue
142
+ target = dst / rel
143
+ if p.is_dir():
144
+ target.mkdir(parents=True, exist_ok=True)
145
+ elif p.is_file():
146
+ # BUG FIX #5: Skip files that exceed the size limit.
147
+ try:
148
+ if p.stat().st_size > MAX_FILE_SIZE_BYTES:
149
+ continue
150
+ except OSError:
151
+ continue
152
+ target.parent.mkdir(parents=True, exist_ok=True)
153
+ try:
154
+ shutil.copy2(p, target)
155
+ except OSError:
156
+ pass
157
+
158
+ def is_jupyter_running(port: int = 8888) -> bool:
159
+ """Return True if JupyterLab is already listening on *port*.
160
+
161
+ BUG FIX #2 (safety net): restore_once() must never run while JupyterLab
162
+ is active. Overwriting files under JUPYTER_ROOT (runtime/ sockets, lab/
163
+ settings, kernel connection files) while JupyterLab is live corrupts its
164
+ state and causes it to exit within seconds.
165
+
166
+ The primary guard is the --restore / sync separation introduced in
167
+ BUG FIX #3, but this TCP probe stays as a hard backstop for any future
168
+ code path that might call restore_once() unexpectedly.
169
+ """
170
+ try:
171
+ with socket.create_connection(("127.0.0.1", port), timeout=2):
172
+ return True
173
+ except OSError:
174
+ return False
175
+
176
+ def restore_once(api, rid: str):
177
+ from huggingface_hub.errors import RepositoryNotFoundError
178
+ tmp = Path(tempfile.mkdtemp(prefix="devdata-restore-"))
179
+ try:
180
+ snapshot_download(repo_id=rid, repo_type="dataset", local_dir=str(tmp), local_dir_use_symlinks=False, token=HF_TOKEN)
181
+ for p in tmp.rglob("*"):
182
+ rel = p.relative_to(tmp)
183
+ if should_skip(rel):
184
+ continue
185
+ if str(rel) == ".gitattributes":
186
+ continue
187
+ target = JUPYTER_ROOT / rel
188
+ if p.is_dir():
189
+ target.mkdir(parents=True, exist_ok=True)
190
+ elif p.is_file():
191
+ target.parent.mkdir(parents=True, exist_ok=True)
192
+ try:
193
+ shutil.copy2(p, target)
194
+ except OSError as exc:
195
+ kind = classify_error(exc)
196
+ print(f"DevData restore skip [{kind}] (cannot write {target}): {exc}")
197
+ print(f"DevData restored from {rid}")
198
+ except RepositoryNotFoundError:
199
+ print(f"DevData dataset not found yet: {rid}")
200
+ except Exception as exc:
201
+ kind = classify_error(exc)
202
+ print(f"DevData restore warning [{kind}]: {exc}")
203
+ finally:
204
+ shutil.rmtree(tmp, ignore_errors=True)
205
+
206
+ def prune_remote_deleted_files(api, rid: str, snapshot_dir: Path) -> None:
207
+ """BUG FIX #6: Delete from the HF dataset any files the user deleted
208
+ locally. Without this, deleted files re-appear on the next Space restart
209
+ because restore_once() copies everything in the dataset back to disk.
210
+ Mirrors the prune_remote_deleted_files() logic in openclaw-sync.py.
211
+ """
212
+ try:
213
+ local_files = {
214
+ p.relative_to(snapshot_dir).as_posix()
215
+ for p in snapshot_dir.rglob("*")
216
+ if p.is_file()
217
+ }
218
+ remote_files = list(api.list_repo_files(repo_id=rid, repo_type="dataset"))
219
+ stale = [f for f in remote_files if f not in local_files and f != ".gitattributes"]
220
+ if stale:
221
+ api.delete_files(
222
+ delete_patterns=stale,
223
+ repo_id=rid,
224
+ repo_type="dataset",
225
+ commit_message=f"DevData prune {len(stale)} deleted file(s) {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
226
+ )
227
+ print(f"DevData pruned {len(stale)} deleted file(s) from {rid}")
228
+ except Exception as exc:
229
+ kind = classify_error(exc)
230
+ print(f"DevData prune warning [{kind}]: {exc}")
231
+
232
+ def sync_loop(api, rid: str):
233
+ while True:
234
+ tmp = Path(tempfile.mkdtemp(prefix="devdata-snap-"))
235
+ try:
236
+ snapshot(JUPYTER_ROOT, tmp)
237
+ upload_folder(
238
+ folder_path=str(tmp),
239
+ repo_id=rid,
240
+ repo_type="dataset",
241
+ token=HF_TOKEN,
242
+ commit_message=f"DevData sync {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
243
+ ignore_patterns=[".git/*", ".git"],
244
+ )
245
+ print(f"DevData synced to {rid}")
246
+ # BUG FIX #6: Prune files deleted locally so they don't reappear on restore.
247
+ prune_remote_deleted_files(api, rid, tmp)
248
+ except Exception as exc:
249
+ kind = classify_error(exc)
250
+ print(f"DevData sync warning [{kind}]: {exc}")
251
+ finally:
252
+ shutil.rmtree(tmp, ignore_errors=True)
253
+ time.sleep(INTERVAL)
254
+
255
+
256
+ if __name__ == "__main__":
257
+ if not enabled():
258
+ print("DevData sync disabled.")
259
+ raise SystemExit(0)
260
+
261
+ from huggingface_hub import HfApi, upload_folder, snapshot_download
262
+ from huggingface_hub.errors import RepositoryNotFoundError
263
+
264
+ api = HfApi(token=HF_TOKEN)
265
+ rid = repo_id(api)
266
+ try:
267
+ api.repo_info(repo_id=rid, repo_type="dataset")
268
+ except RepositoryNotFoundError:
269
+ api.create_repo(repo_id=rid, repo_type="dataset", private=True)
270
+
271
+ # ── BUG FIX #3: Restore must happen BEFORE JupyterLab starts ──────────
272
+ # The original code always called restore_once() here, but start.sh starts
273
+ # JupyterLab long before the gateway is ready and this script is launched.
274
+ # That made restore_once() ALWAYS run while JupyterLab was live, which
275
+ # overwrote its runtime/ sockets and settings → JupyterLab died.
276
+ #
277
+ # Fix: start.sh now calls `python3 jupyter-devdata-sync.py --restore`
278
+ # BEFORE starting JupyterLab. That --restore invocation does the restore
279
+ # and exits. This background invocation (no --restore flag) skips straight
280
+ # to sync_loop so it never touches files while JupyterLab is running.
281
+ #
282
+ # BUG FIX #2 (safety net): If JupyterLab is somehow already running when
283
+ # this code path is reached, abort restore to avoid corrupting its state.
284
+ if "--restore" in sys.argv:
285
+ # Synchronous restore mode — called by start.sh before JupyterLab.
286
+ validate_jupyter_paths()
287
+ restore_once(api, rid)
288
+ raise SystemExit(0)
289
+
290
+ # Normal background sync mode — no restore; go straight to upload loop.
291
+ validate_jupyter_paths()
292
+ if is_jupyter_running():
293
+ print("DevData: background sync started (JupyterLab is live, restore already done by --restore).")
294
+ else:
295
+ # Fallback: JupyterLab not detected. Should not normally happen
296
+ # because start.sh calls --restore before starting JupyterLab and then
297
+ # waits for the gateway before launching this background process.
298
+ # Log a warning and proceed to sync; do NOT restore to avoid racing
299
+ # with a JupyterLab that may be in the middle of starting up.
300
+ print("DevData: WARNING — JupyterLab not detected on port 8888. Skipping restore to be safe; starting sync loop.")
301
+
302
+ sync_loop(api, rid)
login.html ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "page.html" %}
2
+
3
+ {% block stylesheet %}
4
+ {% endblock %}
5
+
6
+ {% block site %}
7
+ <div id="jupyter-main-app" class="container" style="text-align:center; max-width: 760px; margin-top: 40px;">
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
+ <h5>The default token is <span style="color:orange;">huggingface</span></h5>
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">
44
+ {% for key in message %}
45
+ <div class="message {{key}}">
46
+ {{message[key]}}
47
+ </div>
48
+ {% endfor %}
49
+ </div>
50
+ {% endif %}
51
+ {% if token_available %}
52
+ {% block token_message %}
53
+ {% endblock token_message %}
54
+ {% endif %}
55
+ </div>
56
+ {% endblock %}
57
+
58
+ {% block script %}
59
+ {% endblock %}
multi-provider-key-rotator.cjs CHANGED
@@ -166,6 +166,33 @@ const PROVIDERS = [
166
  envPlural: 'HUGGINGFACE_HUB_TOKENS', // plural variant
167
  envSingular:'HUGGINGFACE_HUB_TOKEN',
168
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  ];
170
 
171
  // ─── Key loading ─────────────────────────────────────────────────────────────
@@ -298,10 +325,9 @@ function patchFetch() {
298
  const headers = init.headers || (input && input.headers) || undefined;
299
  const patchedHeaders = setAuthHeader(headers, key);
300
  init = { ...init, headers: patchedHeaders };
301
-
302
- if (input && typeof input === 'object' && !(input instanceof URL) && input.headers) {
303
- try { input = new Request(input, { headers: patchedHeaders }); } catch { /* noop */ }
304
- }
305
  }
306
  }
307
  }
@@ -335,13 +361,17 @@ function patchHttpModule(mod) {
335
  ? { ...options, path: `${u.pathname}${u.search}` }
336
  : u.toString();
337
  } else if (typeof options === 'string' || options instanceof URL) {
 
 
338
  const u = new URL(String(options));
 
339
  args[0] = {
340
  protocol: u.protocol,
341
  hostname: u.hostname,
342
  port: u.port,
343
  path: `${u.pathname}${u.search}`,
344
- headers: { authorization: `Bearer ${key}` },
 
345
  };
346
  } else if (options && typeof options === 'object') {
347
  args[0] = { ...options, headers: setAuthHeader(options.headers, key) };
 
166
  envPlural: 'HUGGINGFACE_HUB_TOKENS', // plural variant
167
  envSingular:'HUGGINGFACE_HUB_TOKEN',
168
  },
169
+ {
170
+ name: 'venice',
171
+ hostname: /(?:^|\.)api\.venice\.ai$/i,
172
+ envPlural: 'VENICE_API_KEYS',
173
+ envSingular:'VENICE_API_KEY',
174
+ },
175
+ {
176
+ name: 'github-copilot',
177
+ hostname: /(?:^|\.)api\.githubcopilot\.com$/i,
178
+ envPlural: 'COPILOT_GITHUB_TOKENS',
179
+ envSingular:'COPILOT_GITHUB_TOKEN',
180
+ },
181
+ {
182
+ name: 'qianfan',
183
+ // Baidu Qianfan / ERNIE
184
+ hostname: /(?:^|\.)(?:aip|qianfan)\.baidubce\.com$/i,
185
+ envPlural: 'QIANFAN_API_KEYS',
186
+ envSingular:'QIANFAN_API_KEY',
187
+ },
188
+ {
189
+ name: 'modelstudio',
190
+ // Aliyun DashScope / Qwen (both qwen/* and modelstudio/* prefixes)
191
+ hostname: /(?:^|\.)dashscope\.aliyuncs\.com$/i,
192
+ envPlural: 'MODELSTUDIO_API_KEYS',
193
+ envSingular:'MODELSTUDIO_API_KEY',
194
+ },
195
+
196
  ];
197
 
198
  // ─── Key loading ─────────────────────────────────────────────────────────────
 
325
  const headers = init.headers || (input && input.headers) || undefined;
326
  const patchedHeaders = setAuthHeader(headers, key);
327
  init = { ...init, headers: patchedHeaders };
328
+ // NOTE: new Request(input, {headers}) yahan nahi karte — Request clone karna
329
+ // body stream ko disturb kar deta hai UND_ERR_INVALID_ARG on POST requests.
330
+ // init.headers fetch spec ke mutabiq Request ke headers ko override kar deta hai.
 
331
  }
332
  }
333
  }
 
361
  ? { ...options, path: `${u.pathname}${u.search}` }
362
  : u.toString();
363
  } else if (typeof options === 'string' || options instanceof URL) {
364
+ // Convert string/URL to options object and inject auth header.
365
+ // Also preserve any extra options passed as args[1] (3-arg form of http.request).
366
  const u = new URL(String(options));
367
+ const extraOpts = (args[1] && typeof args[1] === 'object' && typeof args[1].on !== 'function') ? args[1] : {};
368
  args[0] = {
369
  protocol: u.protocol,
370
  hostname: u.hostname,
371
  port: u.port,
372
  path: `${u.pathname}${u.search}`,
373
+ ...extraOpts,
374
+ headers: setAuthHeader(extraOpts.headers, key),
375
  };
376
  } else if (options && typeof options === 'object') {
377
  args[0] = { ...options, headers: setAuthHeader(options.headers, key) };
openclaw-sync.py CHANGED
@@ -53,8 +53,11 @@ CONFIG_SETTLE_SECONDS = max(
53
  HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
54
  HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
55
  SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
56
- BACKUP_DATASET_NAME = os.environ.get("BACKUP_DATASET_NAME", "huggingclaw-backup").strip()
57
- WHATSAPP_ENABLED = os.environ.get("WHATSAPP_ENABLED", "").strip().lower() == "true"
 
 
 
58
 
59
  EXCLUDED_SYNC_DIRS = {
60
  "node_modules", ".git", "__pycache__", ".venv", "venv",
 
53
  HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
54
  HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
55
  SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
56
+ BACKUP_DATASET_NAME = os.environ.get("BACKUP_DATASET_NAME", "").strip() or os.environ.get("BACKUP_DATASET", "").strip() or "huggingclaw-backup"
57
+ def is_true(value):
58
+ return str(value).strip().lower() in {"1", "true", "yes", "on"}
59
+
60
+ WHATSAPP_ENABLED = is_true(os.environ.get("WHATSAPP_ENABLED", ""))
61
 
62
  EXCLUDED_SYNC_DIRS = {
63
  "node_modules", ".git", "__pycache__", ".venv", "venv",
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
@@ -21,7 +84,21 @@ WHATSAPP_ENABLED_CONFIGURED=false
21
  [ "${WHATSAPP_ENABLED+x}" = "x" ] && WHATSAPP_ENABLED_CONFIGURED=true
22
  WHATSAPP_ENABLED="${WHATSAPP_ENABLED:-false}"
23
  WHATSAPP_ENABLED_NORMALIZED=$(printf '%s' "$WHATSAPP_ENABLED" | tr '[:upper:]' '[:lower:]')
24
- SYNC_INTERVAL="${SYNC_INTERVAL:-180}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  if [ -n "${SPACE_HOST:-}" ]; then
26
  OPENCLAW_CONSOLE_LOG_LEVEL="${OPENCLAW_CONSOLE_LOG_LEVEL:-warn}"
27
  OPENCLAW_FILE_LOG_LEVEL="${OPENCLAW_FILE_LOG_LEVEL:-info}"
@@ -39,7 +116,7 @@ else
39
  fi
40
  echo ""
41
  echo " ╔══════════════════════════════════════════╗"
42
- echo " ║ 🦞 HuggingClaw Gateway ║"
43
  echo " ╚══════════════════════════════════════════╝"
44
  echo ""
45
 
@@ -57,7 +134,7 @@ fi
57
  if [ -n "$ERRORS" ]; then
58
  echo "Missing required secrets:"
59
  echo -e "$ERRORS"
60
- echo "Add them in HF Spaces → Settings → Secrets"
61
  exit 1
62
  fi
63
 
@@ -175,6 +252,14 @@ promote_first_pool_key "CEREBRAS_API_KEY" "CEREBRAS_API_KEYS"
175
  promote_first_pool_key "VENICE_API_KEY" "VENICE_API_KEYS"
176
  promote_first_pool_key "SYNTHETIC_API_KEY" "SYNTHETIC_API_KEYS"
177
  promote_first_pool_key "COPILOT_GITHUB_TOKEN" "COPILOT_GITHUB_TOKENS"
 
 
 
 
 
 
 
 
178
  promote_first_pool_key "HUGGINGFACE_HUB_TOKEN" "HUGGINGFACE_HUB_TOKENS"
179
 
180
  # Compatibility aliases for Google provider secrets some users already have.
@@ -200,6 +285,11 @@ export NPM_CONFIG_PREFIX="${NPM_CONFIG_PREFIX:-/home/node/.local}"
200
  export npm_config_prefix="$NPM_CONFIG_PREFIX"
201
  export PYTHONUSERBASE="${PYTHONUSERBASE:-/home/node/.local}"
202
  export DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"
 
 
 
 
 
203
  STARTUP_FILE="/home/node/.openclaw/workspace/startup.sh"
204
 
205
  # ── Restore workspace/state from HF Dataset ──
@@ -433,6 +523,8 @@ fi
433
  # plugins that the Control UI/dashboard needs to render correctly
434
  # on HF Spaces. Without these the UI shows blank panels.
435
  # telegram/whatsapp/browser/acpx are added conditionally below.
 
 
436
  # DENY: lmstudio crashes on boot when no local server is reachable;
437
  # xai PLUGIN (separate from the xai model PROVIDER) is broken in
438
  # current OpenClaw releases and prevents gateway start. Disabling
@@ -452,20 +544,17 @@ if [ "$WHATSAPP_ENABLED_NORMALIZED" = "true" ]; then
452
  fi
453
 
454
  # Apply plugin allow/deny + per-entry toggles in one jq pass.
455
- ACPX_DISABLED=false
456
- if [ "$ACP_PLUGIN_MODE" = "disabled" ]; then ACPX_DISABLED=true; fi
457
  BROWSER_DISABLED=true
458
  if [ "$BROWSER_SHOULD_ENABLE" = "true" ]; then BROWSER_DISABLED=false; fi
459
 
460
  CONFIG_JSON=$(jq \
461
  --argjson allow "$PLUGIN_ALLOW_JSON" \
462
- --argjson acpxDisabled "$ACPX_DISABLED" \
463
  --argjson browserDisabled "$BROWSER_DISABLED" \
464
  '.plugins.allow = $allow
465
  | .plugins.deny = ["lmstudio","xai"]
466
  | .plugins.entries.lmstudio.enabled = false
467
  | .plugins.entries.xai.enabled = false
468
- | (if $acpxDisabled then .plugins.entries.acpx.enabled = false else . end)
469
  | (if $browserDisabled then
470
  .plugins.entries.browser.enabled = false | .browser.enabled = false
471
  else . end)' <<<"$CONFIG_JSON")
@@ -513,6 +602,24 @@ if [ -n "${ALLOWED_ORIGINS:-}" ]; then
513
  CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".gateway.controlUi.allowedOrigins += $ORIGINS_JSON | .gateway.controlUi.allowedOrigins |= unique")
514
  fi
515
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
516
  # Telegram (supports multiple user IDs, comma-separated)
517
  if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
518
  CONFIG_JSON=$(echo "$CONFIG_JSON" | jq '.plugins.entries.telegram = {"enabled": true}')
@@ -525,12 +632,12 @@ if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
525
  # Force ipv4 for Telegram specifically as HF IPv6 often times out
526
  export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--dns-result-order=ipv4first"
527
 
528
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq --arg token "$CLEAN_TG_TOKEN" --arg proxy_url "${CLOUDFLARE_PROXY_URL:-}" '
529
  .channels.telegram.enabled = true
530
  | .channels.telegram.botToken = $token
531
  | .channels.telegram.commands.native = false
532
  | .channels.telegram.timeoutSeconds = 60
533
- | (if $proxy_url != "" then .channels.telegram.apiRoot = $proxy_url else .channels.telegram.apiRoot = "https://api.telegram.org" end)
534
  | .channels.telegram.retry = {
535
  "attempts": 5,
536
  "minDelayMs": 800,
@@ -594,7 +701,7 @@ if [ -f "$EXISTING_CONFIG" ]; then
594
  | .channels = ((.channels // {}) * ($desired.channels // {}))
595
  | .plugins.allow = (((.plugins.allow // []) + ($desired.plugins.allow // [])) | unique)
596
  | .plugins.deny = (((.plugins.deny // []) + ($desired.plugins.deny // [])) | unique)
597
- | .plugins.entries = (($desired.plugins.entries // {}) * (.plugins.entries // {}))
598
  | if $whatsappEnabled then
599
  ($desired.channels.whatsapp // {"dmPolicy": "pairing"}) as $desiredWhatsapp
600
  | .plugins.entries.whatsapp.enabled = true
@@ -649,8 +756,34 @@ fi
649
  if [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
650
  echo "Proxy : ${CLOUDFLARE_PROXY_URL}"
651
  fi
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  if [ -n "${SPACE_HOST:-}" ]; then
653
- echo "Control UI: https://${SPACE_HOST}/app"
 
 
 
 
 
654
  fi
655
  echo ""
656
 
@@ -709,6 +842,105 @@ export LLM_MODEL="$LLM_MODEL"
709
  node /home/node/app/health-server.js &
710
  HEALTH_PID=$!
711
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
712
  if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then
713
  echo "Setting up Cloudflare KeepAlive monitor..."
714
  python3 /home/node/app/cloudflare-keepalive-setup.py || true
@@ -728,8 +960,14 @@ export NPM_CONFIG_PREFIX="${NPM_CONFIG_PREFIX:-/home/node/.local}"
728
  export npm_config_prefix="$NPM_CONFIG_PREFIX"
729
  export PYTHONUSERBASE="${PYTHONUSERBASE:-/home/node/.local}"
730
  export DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"
 
 
 
731
  STARTUP_FILE="/home/node/.openclaw/workspace/startup.sh"
732
  _hc_append() {
 
 
 
733
  local line="$*"
734
  mkdir -p "$(dirname "$STARTUP_FILE")"
735
  touch "$STARTUP_FILE"
@@ -756,6 +994,28 @@ _hc_append_cmd() {
756
  _hc_append "$cmd"
757
  fi
758
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
759
  _hc_allow_openclaw_plugins() {
760
  local config="/home/node/.openclaw/openclaw.json"
761
  [ -f "$config" ] || return 0
@@ -807,7 +1067,7 @@ apt-get() {
807
  _hc_apt_install "$@"
808
  local rc=$?
809
  if [ $rc -eq 0 ]; then
810
- _hc_append_cmd "sudo apt-get update && sudo apt-get install -y" "$@"
811
  fi
812
  return $rc
813
  ;;
@@ -834,7 +1094,7 @@ apt() {
834
  _hc_apt_install "$@"
835
  local rc=$?
836
  if [ $rc -eq 0 ]; then
837
- _hc_append_cmd "sudo apt-get update && sudo apt-get install -y" "$@"
838
  fi
839
  return $rc
840
  ;;
@@ -856,32 +1116,65 @@ apt() {
856
  }
857
  pip() {
858
  if [ "${1:-}" = "install" ] && [ -z "${VIRTUAL_ENV:-}" ] && ! _hc_has_arg --user "$@" && ! _hc_has_arg --prefix "$@"; then
859
- command pip install --user "${@:2}"
860
  else
861
  command pip "$@"
862
  fi
863
  local rc=$?
864
- if [ $rc -eq 0 ] && [ "${1:-}" = "install" ]; then
 
 
 
865
  _hc_append_cmd "python3 -m pip install --user" "${@:2}"
866
  fi
867
  return $rc
868
  }
869
  pip3() {
870
  if [ "${1:-}" = "install" ] && [ -z "${VIRTUAL_ENV:-}" ] && ! _hc_has_arg --user "$@" && ! _hc_has_arg --prefix "$@"; then
871
- command pip3 install --user "${@:2}"
872
  else
873
  command pip3 "$@"
874
  fi
875
  local rc=$?
876
- if [ $rc -eq 0 ] && [ "${1:-}" = "install" ]; then
 
 
877
  _hc_append_cmd "python3 -m pip install --user" "${@:2}"
878
  fi
879
  return $rc
880
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
881
  npm() {
882
  command npm "$@"
883
  local rc=$?
884
- if [ $rc -eq 0 ] && [ "${1:-}" = "install" ] && { [ "${2:-}" = "-g" ] || [ "${2:-}" = "--global" ]; }; then
885
  _hc_append_cmd "npm install -g" "${@:3}"
886
  fi
887
  return $rc
@@ -889,15 +1182,37 @@ npm() {
889
  openclaw() {
890
  command openclaw "$@"
891
  local rc=$?
892
- if [ $rc -eq 0 ] && [ "${1:-}" = "plugins" ] && [ "${2:-}" = "install" ]; then
893
  _hc_allow_openclaw_plugins "${@:3}"
894
  _hc_append_cmd "openclaw plugins install" "${@:3}"
895
  fi
896
  return $rc
897
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
898
  BASHRC
899
  cat > /home/node/.profile <<'PROFILE'
900
- [ -f ~/.bashrc ] && . ~/.bashrc
901
  PROFILE
902
  echo "Shell capture wrappers ready."
903
 
@@ -935,7 +1250,7 @@ hc_run_startup_command() {
935
 
936
  echo "[startup:${source_label}] $command_text"
937
  set +e
938
- bash -lc "$command_text"
939
  local rc=$?
940
  set -e
941
  if [ "$rc" -eq 0 ]; then
@@ -959,6 +1274,7 @@ hc_run_startup_script() {
959
  # Load HuggingClaw's install wrappers for env-provided scripts too, so
960
  # `apt install`, `pip install`, `npm install -g`, and OpenClaw plugin
961
  # installs behave the same way as they do in the interactive shell.
 
962
  echo '[ -f /home/node/.bashrc ] && . /home/node/.bashrc'
963
  printf '%s\n' "$script_text"
964
  } > "$script_file"
@@ -1076,7 +1392,7 @@ fi
1076
  if [ -n "${HUGGINGCLAW_PIP_PACKAGES:-}" ]; then
1077
  echo "Installing Python packages from HUGGINGCLAW_PIP_PACKAGES..."
1078
  read -r -a HC_PIP_PACKAGES <<< "$HUGGINGCLAW_PIP_PACKAGES"
1079
- if python3 -m pip install --user "${HC_PIP_PACKAGES[@]}"; then
1080
  echo "HUGGINGCLAW_PIP_PACKAGES install complete."
1081
  else
1082
  HC_STARTUP_FAILURES=$((HC_STARTUP_FAILURES + 1))
@@ -1165,6 +1481,38 @@ sync_before_gateway_restart() {
1165
  echo "Warning: could not sync settled state before gateway restart"
1166
  }
1167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1168
  start_background_sync_once() {
1169
  [ -n "${HF_TOKEN:-}" ] || return 0
1170
 
@@ -1172,7 +1520,7 @@ start_background_sync_once() {
1172
  return 0
1173
  fi
1174
 
1175
- python3 -u /home/node/app/openclaw-sync.py loop &
1176
  SYNC_LOOP_PID=$!
1177
  }
1178
 
@@ -1189,6 +1537,30 @@ start_guardian_once() {
1189
  }
1190
 
1191
  while true; do
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1192
  echo "Launching OpenClaw gateway on port 7860..."
1193
 
1194
  GATEWAY_ARGS=(gateway run --port 7860 --bind lan)
@@ -1238,6 +1610,7 @@ while true; do
1238
  # config edits can make OpenClaw exit/reload, and the gateway loop below will
1239
  # relaunch it without rerunning all startup code.
1240
  start_background_sync_once
 
1241
 
1242
  set +e
1243
  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
 
84
  [ "${WHATSAPP_ENABLED+x}" = "x" ] && WHATSAPP_ENABLED_CONFIGURED=true
85
  WHATSAPP_ENABLED="${WHATSAPP_ENABLED:-false}"
86
  WHATSAPP_ENABLED_NORMALIZED=$(printf '%s' "$WHATSAPP_ENABLED" | tr '[:upper:]' '[:lower:]')
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}"
 
116
  fi
117
  echo ""
118
  echo " ╔══════════════════════════════════════════╗"
119
+ echo " ║ 🦞 HuggingClaw + 💻 JupyterLab ║"
120
  echo " ╚══════════════════════════════════════════╝"
121
  echo ""
122
 
 
134
  if [ -n "$ERRORS" ]; then
135
  echo "Missing required secrets:"
136
  echo -e "$ERRORS"
137
+ echo "Add them in HF Spaces → Settings → Secrets"
138
  exit 1
139
  fi
140
 
 
252
  promote_first_pool_key "VENICE_API_KEY" "VENICE_API_KEYS"
253
  promote_first_pool_key "SYNTHETIC_API_KEY" "SYNTHETIC_API_KEYS"
254
  promote_first_pool_key "COPILOT_GITHUB_TOKEN" "COPILOT_GITHUB_TOKENS"
255
+ promote_first_pool_key "AI_GATEWAY_API_KEY" "AI_GATEWAY_API_KEYS"
256
+
257
+ # kimi-coding uses Moonshot AI endpoint (api.moonshot.cn).
258
+ # If KIMI_API_KEY is set but MOONSHOT_API_KEY is not, mirror it so the
259
+ # multi-provider-key-rotator (which matches on api.moonshot.cn) injects it.
260
+ if [ -z "${MOONSHOT_API_KEY:-}" ] && [ -n "${KIMI_API_KEY:-}" ]; then
261
+ export MOONSHOT_API_KEY="$KIMI_API_KEY"
262
+ fi
263
  promote_first_pool_key "HUGGINGFACE_HUB_TOKEN" "HUGGINGFACE_HUB_TOKENS"
264
 
265
  # Compatibility aliases for Google provider secrets some users already have.
 
285
  export npm_config_prefix="$NPM_CONFIG_PREFIX"
286
  export PYTHONUSERBASE="${PYTHONUSERBASE:-/home/node/.local}"
287
  export DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"
288
+ # Show current working directory in terminal prompt (JupyterLab terminals can
289
+ # otherwise display only "$" when PS1 is unset/minimal).
290
+ if [ -z "${PS1:-}" ] || [ "$PS1" = "$ " ]; then
291
+ export PS1='\u@\h:\w\$ '
292
+ fi
293
  STARTUP_FILE="/home/node/.openclaw/workspace/startup.sh"
294
 
295
  # ── Restore workspace/state from HF Dataset ──
 
523
  # plugins that the Control UI/dashboard needs to render correctly
524
  # on HF Spaces. Without these the UI shows blank panels.
525
  # telegram/whatsapp/browser/acpx are added conditionally below.
526
+ # Do not create a disabled acpx entry when the plugin is absent;
527
+ # OpenClaw reports that as a config warning on HF Spaces.
528
  # DENY: lmstudio crashes on boot when no local server is reachable;
529
  # xai PLUGIN (separate from the xai model PROVIDER) is broken in
530
  # current OpenClaw releases and prevents gateway start. Disabling
 
544
  fi
545
 
546
  # Apply plugin allow/deny + per-entry toggles in one jq pass.
 
 
547
  BROWSER_DISABLED=true
548
  if [ "$BROWSER_SHOULD_ENABLE" = "true" ]; then BROWSER_DISABLED=false; fi
549
 
550
  CONFIG_JSON=$(jq \
551
  --argjson allow "$PLUGIN_ALLOW_JSON" \
 
552
  --argjson browserDisabled "$BROWSER_DISABLED" \
553
  '.plugins.allow = $allow
554
  | .plugins.deny = ["lmstudio","xai"]
555
  | .plugins.entries.lmstudio.enabled = false
556
  | .plugins.entries.xai.enabled = false
557
+ | del(.plugins.entries.acpx)
558
  | (if $browserDisabled then
559
  .plugins.entries.browser.enabled = false | .browser.enabled = false
560
  else . end)' <<<"$CONFIG_JSON")
 
602
  CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".gateway.controlUi.allowedOrigins += $ORIGINS_JSON | .gateway.controlUi.allowedOrigins |= unique")
603
  fi
604
 
605
+ resolve_telegram_api_root() {
606
+ local candidate="$(trim_var "${CLOUDFLARE_PROXY_URL:-}")"
607
+ if [ -n "$candidate" ]; then
608
+ case "$candidate" in
609
+ http://*|https://*)
610
+ printf '%s' "$candidate"
611
+ return 0
612
+ ;;
613
+ *)
614
+ echo "Warning: invalid CLOUDFLARE_PROXY_URL '$candidate' (must start with http:// or https://); falling back to direct Telegram API." >&2
615
+ ;;
616
+ esac
617
+ fi
618
+ printf '%s' "https://api.telegram.org"
619
+ }
620
+ TELEGRAM_API_ROOT="$(resolve_telegram_api_root)"
621
+
622
+
623
  # Telegram (supports multiple user IDs, comma-separated)
624
  if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
625
  CONFIG_JSON=$(echo "$CONFIG_JSON" | jq '.plugins.entries.telegram = {"enabled": true}')
 
632
  # Force ipv4 for Telegram specifically as HF IPv6 often times out
633
  export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--dns-result-order=ipv4first"
634
 
635
+ CONFIG_JSON=$(echo "$CONFIG_JSON" | jq --arg token "$CLEAN_TG_TOKEN" --arg proxy_url "$TELEGRAM_API_ROOT" '
636
  .channels.telegram.enabled = true
637
  | .channels.telegram.botToken = $token
638
  | .channels.telegram.commands.native = false
639
  | .channels.telegram.timeoutSeconds = 60
640
+ | (if $proxy_url != "" then .channels.telegram.apiRoot = $proxy_url else . end)
641
  | .channels.telegram.retry = {
642
  "attempts": 5,
643
  "minDelayMs": 800,
 
701
  | .channels = ((.channels // {}) * ($desired.channels // {}))
702
  | .plugins.allow = (((.plugins.allow // []) + ($desired.plugins.allow // [])) | unique)
703
  | .plugins.deny = (((.plugins.deny // []) + ($desired.plugins.deny // [])) | unique)
704
+ | .plugins.entries = ((.plugins.entries // {}) * ($desired.plugins.entries // {}))
705
  | if $whatsappEnabled then
706
  ($desired.channels.whatsapp // {"dmPolicy": "pairing"}) as $desiredWhatsapp
707
  | .plugins.entries.whatsapp.enabled = true
 
756
  if [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
757
  echo "Proxy : ${CLOUDFLARE_PROXY_URL}"
758
  fi
759
+ RUNTIME_JUPYTER_ENABLED="$DEV_MODE_ENABLED"
760
+ # Add user bin to PATH for jupyter-lab (installed in Dockerfile when DEV_MODE=true)
761
+ export PATH="$HOME/.local/bin:$PATH"
762
+
763
+ # Runtime install fallback: only attempt if DEV_MODE is enabled but install failed during build
764
+ if [ "$DEV_MODE_ENABLED" = "true" ] && ! python3 -c "import jupyterlab" >/dev/null 2>&1; then
765
+ echo "DEV_MODE enabled but jupyter-lab is missing; attempting runtime install..."
766
+ if python3 -m pip install --user --no-cache-dir --break-system-packages "jupyterlab>=4.2,<5" "tornado>=6.3" "ipywidgets>=8.1"; then
767
+ echo "Runtime Jupyter install complete."
768
+ 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
769
+ else
770
+ echo "WARNING: Runtime Jupyter install failed; disabling terminal for this boot."
771
+ RUNTIME_JUPYTER_ENABLED=false
772
+ fi
773
+ fi
774
+ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ] && ! python3 -c "import jupyterlab" >/dev/null 2>&1; then
775
+ echo "WARNING: jupyter-lab still unavailable; disabling terminal for this boot."
776
+ RUNTIME_JUPYTER_ENABLED=false
777
+ fi
778
+ export HUGGINGCLAW_JUPYTER_ENABLED="$RUNTIME_JUPYTER_ENABLED"
779
+
780
  if [ -n "${SPACE_HOST:-}" ]; then
781
+ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ]; then
782
+ echo "Routes : /app/ (Control UI), /terminal/ (JupyterLab)"
783
+ else
784
+ echo "Routes : /app/ (Control UI)"
785
+ fi
786
+ echo "Private : open the Hugging Face App tab first; raw https://${SPACE_HOST}/... links can show HF 404 without the embedded Space session."
787
  fi
788
  echo ""
789
 
 
842
  node /home/node/app/health-server.js &
843
  HEALTH_PID=$!
844
 
845
+ start_jupyter_once() {
846
+ [ "$RUNTIME_JUPYTER_ENABLED" = "true" ] || return 0
847
+ if [ -n "${JUPYTER_PID:-}" ] && kill -0 "$JUPYTER_PID" 2>/dev/null; then
848
+ return 0
849
+ fi
850
+
851
+ # Security guard: refuse to start JupyterLab with the insecure default token.
852
+ # JupyterLab exposes a full shell — a weak token is equivalent to no auth.
853
+ if [ -z "${JUPYTER_TOKEN:-}" ] || [ "${JUPYTER_TOKEN}" = "huggingface" ]; then
854
+ echo "ERROR: JUPYTER_TOKEN is unset or still set to the insecure default (\"huggingface\")." >&2
855
+ echo " JupyterLab grants full shell access. Set a strong, unique token in your Space secrets." >&2
856
+ echo " Hint: openssl rand -hex 32" >&2
857
+ echo " DEV_MODE active but JupyterLab will NOT start until JUPYTER_TOKEN is changed." >&2
858
+ return 1
859
+ fi
860
+ JUPYTER_ROOT_DIR="${JUPYTER_ROOT_DIR:-/home/node}"
861
+ if [ "$JUPYTER_ROOT_DIR" = "/home/node/.openclaw/workspace" ] && [ "$DEVDATA_ENABLED" = "true" ]; then
862
+ echo "Jupyter root was set to OpenClaw workspace; moving Jupyter root to /home/node/devdata to keep BACKUP and DEVDATA datasets separate."
863
+ JUPYTER_ROOT_DIR="/home/node/devdata"
864
+ fi
865
+ mkdir -p "$JUPYTER_ROOT_DIR"
866
+ export JUPYTER_ROOT_DIR
867
+ if [ "$JUPYTER_ROOT_DIR" != "/home/node/app" ]; then
868
+ if [ -L "$JUPYTER_ROOT_DIR/HuggingClaw" ] || [ ! -e "$JUPYTER_ROOT_DIR/HuggingClaw" ]; then
869
+ ln -sfn /home/node/app "$JUPYTER_ROOT_DIR/HuggingClaw"
870
+ fi
871
+ fi
872
+ if [ "$JUPYTER_ROOT_DIR" != "/home/node/.openclaw/workspace" ]; then
873
+ if [ -L "$JUPYTER_ROOT_DIR/HuggingClaw-Workspace" ] || [ ! -e "$JUPYTER_ROOT_DIR/HuggingClaw-Workspace" ]; then
874
+ ln -sfn /home/node/.openclaw/workspace "$JUPYTER_ROOT_DIR/HuggingClaw-Workspace"
875
+ fi
876
+ fi
877
+
878
+ # Pre-create runtime directory
879
+ mkdir -p "$JUPYTER_ROOT_DIR/.jupyter"
880
+
881
+ echo "DEV_MODE enabled (${DEV_MODE_RAW}) — starting JupyterLab terminal on internal port 8888 (path: /terminal/) with root: $JUPYTER_ROOT_DIR"
882
+ JUPYTER_LOG_FILE="/tmp/jupyterlab.log"
883
+
884
+ # Use explicit Python to avoid PATH issues; set memory-friendly limits
885
+ export PYTHONPATH=""
886
+ python3 -m jupyterlab \
887
+ --ip 127.0.0.1 \
888
+ --port 8888 \
889
+ --no-browser \
890
+ --IdentityProvider.token="$JUPYTER_TOKEN" \
891
+ --ServerApp.base_url=/terminal/ \
892
+ --ServerApp.terminals_enabled=True \
893
+ --ServerApp.terminado_settings='{"shell_command":["/bin/bash","-i"]}' \
894
+ --ServerApp.allow_origin='*' \
895
+ --ServerApp.allow_remote_access=True \
896
+ --ServerApp.trust_xheaders=True \
897
+ --ServerApp.tornado_settings="{'headers': {'Content-Security-Policy': 'frame-ancestors *'}}" \
898
+ --IdentityProvider.cookie_options="{'SameSite': 'None', 'Secure': True}" \
899
+ --ServerApp.disable_check_xsrf=True \
900
+ --LabApp.news_url=None \
901
+ --LabApp.check_for_updates_class=jupyterlab.NeverCheckForUpdate \
902
+ --ServerApp.log_level=WARN \
903
+ --ServerApp.root_dir="$JUPYTER_ROOT_DIR" \
904
+ >> "$JUPYTER_LOG_FILE" 2>&1 &
905
+ JUPYTER_PID=$!
906
+ export JUPYTER_PID
907
+ echo "JupyterLab started (PID: $JUPYTER_PID)"
908
+ }
909
+
910
+ # BUG FIX #3: DevData restore must happen BEFORE JupyterLab starts.
911
+ # The background jupyter-devdata-sync.py process is only launched AFTER the
912
+ # gateway is ready (20-90 s from now). If restore ran there, JupyterLab would
913
+ # already be live and the file writes would corrupt its runtime state → crash.
914
+ # Running --restore here (synchronous, before JupyterLab) solves that.
915
+ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ] && \
916
+ [ "$DEVDATA_ENABLED" = "true" ] && \
917
+ [ -n "${HF_TOKEN:-}" ] && \
918
+ [ -f "/home/node/app/jupyter-devdata-sync.py" ] && \
919
+ [ "${DEVDATA_DATASET_NAME:-huggingclaw-devdata}" != "${BACKUP_DATASET_NAME:-huggingclaw-backup}" ]; then
920
+ echo "DevData : restoring workspace from ${DEVDATA_DATASET_NAME:-huggingclaw-devdata} (before JupyterLab starts)..."
921
+ python3 /home/node/app/jupyter-devdata-sync.py --restore || \
922
+ echo "DevData : restore warning (non-fatal); continuing startup."
923
+ fi
924
+
925
+ # Fix: reinstall jsonschema AFTER devdata restore — restore can overwrite a broken
926
+ # version from .local/lib/python3.11/site-packages into the workspace, causing
927
+ # JupyterLab to crash with a circular import error on every boot.
928
+ if [ "$DEV_MODE_ENABLED" = "true" ]; then
929
+ if ! python3 -c "import jsonschema" >/dev/null 2>&1; then
930
+ echo "DevData : jsonschema broken after restore — reinstalling (circular import fix)..."
931
+ python3 -m pip install --force-reinstall --no-cache-dir --break-system-packages "jsonschema>=4.0" >/dev/null 2>&1 || true
932
+ echo "DevData : jsonschema reinstall done."
933
+ fi
934
+ fi
935
+
936
+ # 10.5. Start JupyterLab Terminal on internal port 8888 (DEV_MODE only)
937
+ # Accessible via /terminal/ path through the health-server proxy
938
+ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ]; then
939
+ start_jupyter_once
940
+ else
941
+ echo "Jupyter terminal disabled for this boot (DEV_MODE=${DEV_MODE_RAW})."
942
+ fi
943
+
944
  if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then
945
  echo "Setting up Cloudflare KeepAlive monitor..."
946
  python3 /home/node/app/cloudflare-keepalive-setup.py || true
 
960
  export npm_config_prefix="$NPM_CONFIG_PREFIX"
961
  export PYTHONUSERBASE="${PYTHONUSERBASE:-/home/node/.local}"
962
  export DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"
963
+ if [ -z "${PS1:-}" ] || [ "$PS1" = "$ " ]; then
964
+ export PS1="\u@\h:\w\$ "
965
+ fi
966
  STARTUP_FILE="/home/node/.openclaw/workspace/startup.sh"
967
  _hc_append() {
968
+ if [ "${HUGGINGCLAW_CAPTURE_DISABLE:-0}" = "1" ]; then
969
+ return 0
970
+ fi
971
  local line="$*"
972
  mkdir -p "$(dirname "$STARTUP_FILE")"
973
  touch "$STARTUP_FILE"
 
994
  _hc_append "$cmd"
995
  fi
996
  }
997
+ _hc_args_without_flags() {
998
+ local out=()
999
+ local arg
1000
+ for arg in "$@"; do
1001
+ case "$arg" in
1002
+ ''|-) ;;
1003
+ --*) ;;
1004
+ -*) ;;
1005
+ *) out+=("$arg") ;;
1006
+ esac
1007
+ done
1008
+ printf '%s\n' "${out[@]}"
1009
+ }
1010
+ _hc_has_install_targets() {
1011
+ local item
1012
+ while IFS= read -r item; do
1013
+ [ -n "$item" ] && return 0
1014
+ done <<EOF
1015
+ $(_hc_args_without_flags "$@")
1016
+ EOF
1017
+ return 1
1018
+ }
1019
  _hc_allow_openclaw_plugins() {
1020
  local config="/home/node/.openclaw/openclaw.json"
1021
  [ -f "$config" ] || return 0
 
1067
  _hc_apt_install "$@"
1068
  local rc=$?
1069
  if [ $rc -eq 0 ]; then
1070
+ _hc_has_install_targets "$@" && _hc_append_cmd "sudo apt-get update && sudo apt-get install -y" "$@"
1071
  fi
1072
  return $rc
1073
  ;;
 
1094
  _hc_apt_install "$@"
1095
  local rc=$?
1096
  if [ $rc -eq 0 ]; then
1097
+ _hc_has_install_targets "$@" && _hc_append_cmd "sudo apt-get update && sudo apt-get install -y" "$@"
1098
  fi
1099
  return $rc
1100
  ;;
 
1116
  }
1117
  pip() {
1118
  if [ "${1:-}" = "install" ] && [ -z "${VIRTUAL_ENV:-}" ] && ! _hc_has_arg --user "$@" && ! _hc_has_arg --prefix "$@"; then
1119
+ command pip install --user --break-system-packages "${@:2}"
1120
  else
1121
  command pip "$@"
1122
  fi
1123
  local rc=$?
1124
+ # Skip capture when -r/--requirement is used: the requirements file won't exist on next boot
1125
+ if [ $rc -eq 0 ] && [ "${1:-}" = "install" ] \
1126
+ && ! _hc_has_arg -r "${@:2}" && ! _hc_has_arg --requirement "${@:2}" \
1127
+ && _hc_has_install_targets "${@:2}"; then
1128
  _hc_append_cmd "python3 -m pip install --user" "${@:2}"
1129
  fi
1130
  return $rc
1131
  }
1132
  pip3() {
1133
  if [ "${1:-}" = "install" ] && [ -z "${VIRTUAL_ENV:-}" ] && ! _hc_has_arg --user "$@" && ! _hc_has_arg --prefix "$@"; then
1134
+ command pip3 install --user --break-system-packages "${@:2}"
1135
  else
1136
  command pip3 "$@"
1137
  fi
1138
  local rc=$?
1139
+ if [ $rc -eq 0 ] && [ "${1:-}" = "install" ] \
1140
+ && ! _hc_has_arg -r "${@:2}" && ! _hc_has_arg --requirement "${@:2}" \
1141
+ && _hc_has_install_targets "${@:2}"; then
1142
  _hc_append_cmd "python3 -m pip install --user" "${@:2}"
1143
  fi
1144
  return $rc
1145
  }
1146
+ python() {
1147
+ if [ "${1:-}" = "-m" ] && [ "${2:-}" = "pip" ] && [ "${3:-}" = "install" ] && [ -z "${VIRTUAL_ENV:-}" ] && ! _hc_has_arg --user "${@:3}" && ! _hc_has_arg --prefix "${@:3}"; then
1148
+ command python -m pip install --user --break-system-packages "${@:4}"
1149
+ else
1150
+ command python "$@"
1151
+ fi
1152
+ local rc=$?
1153
+ if [ $rc -eq 0 ] && [ "${1:-}" = "-m" ] && [ "${2:-}" = "pip" ] && [ "${3:-}" = "install" ] \
1154
+ && ! _hc_has_arg -r "${@:4}" && ! _hc_has_arg --requirement "${@:4}" \
1155
+ && _hc_has_install_targets "${@:4}"; then
1156
+ _hc_append_cmd "python3 -m pip install --user" "${@:4}"
1157
+ fi
1158
+ return $rc
1159
+ }
1160
+ python3() {
1161
+ if [ "${1:-}" = "-m" ] && [ "${2:-}" = "pip" ] && [ "${3:-}" = "install" ] && [ -z "${VIRTUAL_ENV:-}" ] && ! _hc_has_arg --user "${@:3}" && ! _hc_has_arg --prefix "${@:3}"; then
1162
+ command python3 -m pip install --user --break-system-packages "${@:4}"
1163
+ else
1164
+ command python3 "$@"
1165
+ fi
1166
+ local rc=$?
1167
+ if [ $rc -eq 0 ] && [ "${1:-}" = "-m" ] && [ "${2:-}" = "pip" ] && [ "${3:-}" = "install" ] \
1168
+ && ! _hc_has_arg -r "${@:4}" && ! _hc_has_arg --requirement "${@:4}" \
1169
+ && _hc_has_install_targets "${@:4}"; then
1170
+ _hc_append_cmd "python3 -m pip install --user" "${@:4}"
1171
+ fi
1172
+ return $rc
1173
+ }
1174
  npm() {
1175
  command npm "$@"
1176
  local rc=$?
1177
+ if [ $rc -eq 0 ] && { [ "${1:-}" = "install" ] || [ "${1:-}" = "i" ]; } && { [ "${2:-}" = "-g" ] || [ "${2:-}" = "--global" ]; } && _hc_has_install_targets "${@:3}"; then
1178
  _hc_append_cmd "npm install -g" "${@:3}"
1179
  fi
1180
  return $rc
 
1182
  openclaw() {
1183
  command openclaw "$@"
1184
  local rc=$?
1185
+ if [ $rc -eq 0 ] && [ "${1:-}" = "plugins" ] && [ "${2:-}" = "install" ] && _hc_has_install_targets "${@:3}"; then
1186
  _hc_allow_openclaw_plugins "${@:3}"
1187
  _hc_append_cmd "openclaw plugins install" "${@:3}"
1188
  fi
1189
  return $rc
1190
  }
1191
+ # uv pip install — increasingly popular fast pip replacement
1192
+ uv() {
1193
+ command uv "$@"
1194
+ local rc=$?
1195
+ # Only capture: uv pip install ... (not uv pip sync, uv add, etc.)
1196
+ # Skip if -r/--requirements flag present (file won't exist on next boot)
1197
+ if [ $rc -eq 0 ] && [ "${1:-}" = "pip" ] && [ "${2:-}" = "install" ] \
1198
+ && ! _hc_has_arg -r "${@:3}" && ! _hc_has_arg --requirements "${@:3}" \
1199
+ && _hc_has_install_targets "${@:3}"; then
1200
+ _hc_append_cmd "uv pip install" "${@:3}"
1201
+ fi
1202
+ return $rc
1203
+ }
1204
+ # pipx — isolated tool installs
1205
+ pipx() {
1206
+ command pipx "$@"
1207
+ local rc=$?
1208
+ if [ $rc -eq 0 ] && [ "${1:-}" = "install" ] && _hc_has_install_targets "${@:2}"; then
1209
+ _hc_append_cmd "pipx install" "${@:2}"
1210
+ fi
1211
+ return $rc
1212
+ }
1213
  BASHRC
1214
  cat > /home/node/.profile <<'PROFILE'
1215
+ [ -n "${BASH_VERSION:-}" ] && [ -f ~/.bashrc ] && . ~/.bashrc
1216
  PROFILE
1217
  echo "Shell capture wrappers ready."
1218
 
 
1250
 
1251
  echo "[startup:${source_label}] $command_text"
1252
  set +e
1253
+ HUGGINGCLAW_CAPTURE_DISABLE=1 bash -lc "$command_text"
1254
  local rc=$?
1255
  set -e
1256
  if [ "$rc" -eq 0 ]; then
 
1274
  # Load HuggingClaw's install wrappers for env-provided scripts too, so
1275
  # `apt install`, `pip install`, `npm install -g`, and OpenClaw plugin
1276
  # installs behave the same way as they do in the interactive shell.
1277
+ echo 'export HUGGINGCLAW_CAPTURE_DISABLE=1'
1278
  echo '[ -f /home/node/.bashrc ] && . /home/node/.bashrc'
1279
  printf '%s\n' "$script_text"
1280
  } > "$script_file"
 
1392
  if [ -n "${HUGGINGCLAW_PIP_PACKAGES:-}" ]; then
1393
  echo "Installing Python packages from HUGGINGCLAW_PIP_PACKAGES..."
1394
  read -r -a HC_PIP_PACKAGES <<< "$HUGGINGCLAW_PIP_PACKAGES"
1395
+ if python3 -m pip install --user --break-system-packages "${HC_PIP_PACKAGES[@]}"; then
1396
  echo "HUGGINGCLAW_PIP_PACKAGES install complete."
1397
  else
1398
  HC_STARTUP_FAILURES=$((HC_STARTUP_FAILURES + 1))
 
1481
  echo "Warning: could not sync settled state before gateway restart"
1482
  }
1483
 
1484
+ start_background_devdata_sync() {
1485
+ if [ "$DEV_MODE_ENABLED" != "true" ]; then
1486
+ return 0
1487
+ fi
1488
+ if [ "$DEVDATA_ENABLED" != "true" ]; then
1489
+ echo "DevData : disabled by DEVDATA=${DEVDATA_RAW}"
1490
+ return 0
1491
+ fi
1492
+ if [ -z "${HF_TOKEN:-}" ]; then
1493
+ echo "DevData : disabled (HF_TOKEN missing)"
1494
+ return 0
1495
+ fi
1496
+ if [ "${DEVDATA_DATASET_NAME:-huggingclaw-devdata}" = "${BACKUP_DATASET_NAME:-huggingclaw-backup}" ]; then
1497
+ echo "DevData : disabled (DEVDATA_DATASET_NAME must be separate from BACKUP_DATASET_NAME)"
1498
+ return 0
1499
+ fi
1500
+ if [ ! -f "/home/node/app/jupyter-devdata-sync.py" ]; then
1501
+ echo "DevData : script missing; skipped"
1502
+ return 0
1503
+ fi
1504
+ # BUG FIX #1: Guard against spawning a second devdata-sync process on every
1505
+ # gateway restart. Without this check, each restart launched a fresh
1506
+ # jupyter-devdata-sync.py which called restore_once() while JupyterLab was
1507
+ # already running, corrupting its runtime state and killing it.
1508
+ if [ -n "${DEVDATA_SYNC_PID:-}" ] && kill -0 "$DEVDATA_SYNC_PID" 2>/dev/null; then
1509
+ return 0
1510
+ fi
1511
+ echo "DevData : enabled (dataset=${DEVDATA_DATASET_NAME:-huggingclaw-devdata})"
1512
+ python3 -u /home/node/app/jupyter-devdata-sync.py >> /tmp/devdata-sync.log 2>&1 &
1513
+ DEVDATA_SYNC_PID=$!
1514
+ }
1515
+
1516
  start_background_sync_once() {
1517
  [ -n "${HF_TOKEN:-}" ] || return 0
1518
 
 
1520
  return 0
1521
  fi
1522
 
1523
+ python3 -u /home/node/app/openclaw-sync.py loop >> /tmp/workspace-sync.log 2>&1 &
1524
  SYNC_LOOP_PID=$!
1525
  }
1526
 
 
1537
  }
1538
 
1539
  while true; do
1540
+ # Check health-server process - restart if died unexpectedly
1541
+ if [ -n "${HEALTH_PID:-}" ] && ! kill -0 "$HEALTH_PID" 2>/dev/null; then
1542
+ echo "Warning: health-server exited (PID $HEALTH_PID dead); restarting..."
1543
+ node /home/node/app/health-server.js &
1544
+ HEALTH_PID=$!
1545
+ echo "Health server restarted (PID: $HEALTH_PID)"
1546
+ fi
1547
+
1548
+ # Check JupyterLab process - restart if died unexpectedly
1549
+ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ]; then
1550
+ if [ -n "${JUPYTER_PID:-}" ]; then
1551
+ if ! kill -0 "$JUPYTER_PID" 2>/dev/null; then
1552
+ echo "Warning: JupyterLab exited (PID $JUPYTER_PID dead); checking log..."
1553
+ tail -5 /tmp/jupyterlab.log 2>/dev/null || echo "No log file"
1554
+ echo "Attempting JupyterLab restart..."
1555
+ unset JUPYTER_PID
1556
+ start_jupyter_once
1557
+ fi
1558
+ else
1559
+ # First start
1560
+ start_jupyter_once
1561
+ fi
1562
+ fi
1563
+
1564
  echo "Launching OpenClaw gateway on port 7860..."
1565
 
1566
  GATEWAY_ARGS=(gateway run --port 7860 --bind lan)
 
1610
  # config edits can make OpenClaw exit/reload, and the gateway loop below will
1611
  # relaunch it without rerunning all startup code.
1612
  start_background_sync_once
1613
+ start_background_devdata_sync
1614
 
1615
  set +e
1616
  wait "$GATEWAY_PID"
wa-guardian.js CHANGED
@@ -20,7 +20,7 @@ const { randomUUID } = require('node:crypto');
20
  const GATEWAY_URL = "ws://127.0.0.1:7860";
21
  const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN || "huggingclaw";
22
  const WHATSAPP_ENABLED = /^true$/i.test(process.env.WHATSAPP_ENABLED || "");
23
- const CHECK_INTERVAL = 5000;
24
  const WAIT_TIMEOUT = 120000;
25
  const POST_515_NO_LOGOUT_MS = 90 * 1000;
26
  const SUCCESS_COOLDOWN_MS = 60 * 1000;
@@ -141,7 +141,13 @@ async function callRpc(ws, method, params) {
141
  }
142
  };
143
  ws.on("message", handler);
144
- ws.send(JSON.stringify({ type: "req", id, method, params }));
 
 
 
 
 
 
145
  setTimeout(() => { ws.removeListener("message", handler); reject(new Error("RPC Timeout")); }, WAIT_TIMEOUT + 5000);
146
  });
147
  }
@@ -247,6 +253,13 @@ if (!WHATSAPP_ENABLED) {
247
  process.exit(0);
248
  }
249
 
 
 
 
 
 
 
 
250
  writeStatus({ configured: true, connected: false, pairing: false });
251
  console.log("[guardian] WhatsApp Guardian active. Monitoring pairing status...");
252
  setInterval(checkStatus, CHECK_INTERVAL);
 
20
  const GATEWAY_URL = "ws://127.0.0.1:7860";
21
  const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN || "huggingclaw";
22
  const WHATSAPP_ENABLED = /^true$/i.test(process.env.WHATSAPP_ENABLED || "");
23
+ const CHECK_INTERVAL = 30000;
24
  const WAIT_TIMEOUT = 120000;
25
  const POST_515_NO_LOGOUT_MS = 90 * 1000;
26
  const SUCCESS_COOLDOWN_MS = 60 * 1000;
 
141
  }
142
  };
143
  ws.on("message", handler);
144
+ try {
145
+ ws.send(JSON.stringify({ type: "req", id, method, params }));
146
+ } catch (sendErr) {
147
+ ws.removeListener("message", handler);
148
+ reject(sendErr);
149
+ return;
150
+ }
151
  setTimeout(() => { ws.removeListener("message", handler); reject(new Error("RPC Timeout")); }, WAIT_TIMEOUT + 5000);
152
  });
153
  }
 
253
  process.exit(0);
254
  }
255
 
256
+ process.on("unhandledRejection", (reason) => {
257
+ const msg = reason && reason.message ? reason.message : String(reason);
258
+ if (!/RPC Timeout|Timeout/i.test(msg)) {
259
+ console.log(`[guardian] Unhandled rejection: ${msg}`);
260
+ }
261
+ });
262
+
263
  writeStatus({ configured: true, connected: false, pairing: false });
264
  console.log("[guardian] WhatsApp Guardian active. Monitoring pairing status...");
265
  setInterval(checkStatus, CHECK_INTERVAL);