Anurag commited on
Commit
b6533d1
Β·
unverified Β·
2 Parent(s): dd751b8d5203bf

Merge pull request #88 from anurag008w/edit

Browse files

Enhance JupyterLab integration and improve environment handling

.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,23 @@ 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 +118,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/jupyter-devdata-sync.py \
109
+ /home/node/app/multi-provider-key-rotator.cjs
110
 
111
  USER node
112
 
 
118
 
119
  WORKDIR /home/node/app
120
 
121
+ # 7861 = public entrypoint (dashboard + proxy for both OpenClaw and JupyterLab)
122
  EXPOSE 7861
123
 
 
 
124
  HEALTHCHECK --interval=30s --timeout=5s --start-period=90s \
125
  CMD curl -fsS http://localhost:7861/health || exit 1
126
 
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-setup.py CHANGED
@@ -12,7 +12,8 @@ from pathlib import Path
12
  API_BASE = "https://api.cloudflare.com/client/v4"
13
  ENV_FILE = Path("/tmp/huggingclaw-cloudflare-proxy.env")
14
  DEFAULT_ALLOWED = [
15
- # Messaging
 
16
  "api.telegram.org",
17
  "discord.com",
18
  "discordapp.com",
@@ -32,8 +33,6 @@ DEFAULT_ALLOWED = [
32
  # Video
33
  "youtube.com",
34
  "www.youtube.com",
35
- # AI APIs
36
- "api.openai.com",
37
  # Email HTTP APIs (SMTP ports are blocked; use these instead)
38
  "api.resend.com",
39
  "api.sendgrid.com",
@@ -43,6 +42,11 @@ DEFAULT_ALLOWED = [
43
  "google.com",
44
  "googleusercontent.com",
45
  "gstatic.com",
 
 
 
 
 
46
  ]
47
 
48
 
 
12
  API_BASE = "https://api.cloudflare.com/client/v4"
13
  ENV_FILE = Path("/tmp/huggingclaw-cloudflare-proxy.env")
14
  DEFAULT_ALLOWED = [
15
+ # Messaging & social platforms β€” primary use-case for the Cloudflare proxy
16
+ # on HF Spaces (geo-restrictions on Telegram, Discord, WhatsApp, etc.).
17
  "api.telegram.org",
18
  "discord.com",
19
  "discordapp.com",
 
33
  # Video
34
  "youtube.com",
35
  "www.youtube.com",
 
 
36
  # Email HTTP APIs (SMTP ports are blocked; use these instead)
37
  "api.resend.com",
38
  "api.sendgrid.com",
 
42
  "google.com",
43
  "googleusercontent.com",
44
  "gstatic.com",
45
+ # NOTE: AI-provider domains (api.openai.com, api.anthropic.com, etc.) are
46
+ # intentionally NOT included here. Proxying AI calls routes API keys through
47
+ # the Cloudflare Worker without an explicit opt-in. Users who need AI API
48
+ # calls proxied (e.g. geo-restricted regions) can add specific domains via
49
+ # the CLOUDFLARE_PROXY_DOMAINS environment variable.
50
  ]
51
 
52
 
cloudflare-proxy.js CHANGED
@@ -24,6 +24,8 @@ if (
24
  const DEBUG = process.env.CLOUDFLARE_PROXY_DEBUG === "true";
25
  const PROXY_SHARED_SECRET = (process.env.CLOUDFLARE_PROXY_SECRET || "").trim();
26
  const DEFAULT_PROXY_DOMAINS = [
 
 
27
  "api.telegram.org", "discord.com", "discordapp.com",
28
  "gateway.discord.gg", "status.discord.com", "web.whatsapp.com",
29
  "graph.facebook.com", "graph.instagram.com",
@@ -31,9 +33,15 @@ const DEFAULT_PROXY_DOMAINS = [
31
  "api.linkedin.com", "www.linkedin.com",
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
  ];
38
  const PROXY_DOMAINS_RAW = (process.env.CLOUDFLARE_PROXY_DOMAINS || "").trim();
39
  const PROXY_ALL = PROXY_DOMAINS_RAW === "*";
 
24
  const DEBUG = process.env.CLOUDFLARE_PROXY_DEBUG === "true";
25
  const PROXY_SHARED_SECRET = (process.env.CLOUDFLARE_PROXY_SECRET || "").trim();
26
  const DEFAULT_PROXY_DOMAINS = [
27
+ // Messaging & social platforms β€” these are the primary use-case for the
28
+ // Cloudflare proxy on HF Spaces (geo-restrictions on Telegram, Discord, WA).
29
  "api.telegram.org", "discord.com", "discordapp.com",
30
  "gateway.discord.gg", "status.discord.com", "web.whatsapp.com",
31
  "graph.facebook.com", "graph.instagram.com",
 
33
  "api.linkedin.com", "www.linkedin.com",
34
  "open.tiktokapis.com", "oauth.reddit.com",
35
  "youtube.com", "www.youtube.com",
36
+ // Email delivery
37
  "api.resend.com", "api.sendgrid.com", "api.mailgun.net",
38
+ // Google services
39
  "googleapis.com", "google.com", "googleusercontent.com", "gstatic.com",
40
+ // NOTE: AI-provider domains (api.openai.com, api.anthropic.com, etc.) are
41
+ // intentionally NOT included here. Proxying AI calls routes your API keys
42
+ // through the Cloudflare Worker without an explicit opt-in. Users who need
43
+ // AI API calls proxied (e.g. geo-restricted regions) can add specific
44
+ // domains via the CLOUDFLARE_PROXY_DOMAINS environment variable.
45
  ];
46
  const PROXY_DOMAINS_RAW = (process.env.CLOUDFLARE_PROXY_DOMAINS || "").trim();
47
  const PROXY_ALL = PROXY_DOMAINS_RAW === "*";
env-builder.html ADDED
@@ -0,0 +1,947 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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; height: 100%; }
41
+
42
+ body {
43
+ font-family: var(--sans);
44
+ background: var(--bg);
45
+ color: var(--text);
46
+ height: 100vh;
47
+ overflow: hidden;
48
+ display: flex;
49
+ flex-direction: column;
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
+ .sec-count {
334
+ font-family: var(--mono);
335
+ font-size: 10px;
336
+ color: var(--text3);
337
+ background: var(--bg3);
338
+ border: 1px solid var(--border);
339
+ border-radius: 10px;
340
+ padding: 1px 7px;
341
+ }
342
+
343
+ .sec-line {
344
+ flex: 1;
345
+ height: 1px;
346
+ background: var(--border);
347
+ }
348
+
349
+ .cards {
350
+ display: grid;
351
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
352
+ gap: 9px;
353
+ }
354
+
355
+ /* ── ENV Card ── */
356
+ .env-card {
357
+ background: var(--bg2);
358
+ border: 1px solid var(--border);
359
+ border-radius: var(--r2);
360
+ padding: 12px;
361
+ transition: border-color .2s, background .2s;
362
+ }
363
+
364
+ .env-card:hover { border-color: var(--border2); }
365
+ .env-card.hidden { display: none; }
366
+
367
+ .env-card.selected {
368
+ border-color: var(--amber-glow);
369
+ background: linear-gradient(135deg, var(--bg2) 80%, rgba(245,166,35,.04));
370
+ }
371
+
372
+ /* Critical cards get a red left-border accent */
373
+ .env-card:has(.badge-critical) {
374
+ border-left: 3px solid rgba(240,80,80,.4);
375
+ }
376
+ .env-card:has(.badge-critical):hover {
377
+ border-left-color: rgba(240,80,80,.7);
378
+ }
379
+ .env-card:has(.badge-critical).selected {
380
+ border-left-color: #f05f5f;
381
+ }
382
+
383
+ /* Credential cards get an amber left-border accent */
384
+ .env-card:has(.badge-credential) {
385
+ border-left: 3px solid rgba(220,140,60,.3);
386
+ }
387
+
388
+ .card-top {
389
+ display: flex;
390
+ align-items: flex-start;
391
+ gap: 9px;
392
+ margin-bottom: 9px;
393
+ }
394
+
395
+ .card-check {
396
+ width: 15px;
397
+ height: 15px;
398
+ accent-color: var(--amber);
399
+ flex-shrink: 0;
400
+ margin-top: 2px;
401
+ cursor: pointer;
402
+ }
403
+
404
+ .card-info { flex: 1; min-width: 0; }
405
+
406
+ .card-key {
407
+ font-family: var(--mono);
408
+ font-size: 11.5px;
409
+ font-weight: 600;
410
+ color: var(--text);
411
+ letter-spacing: .3px;
412
+ white-space: nowrap;
413
+ overflow: hidden;
414
+ text-overflow: ellipsis;
415
+ }
416
+
417
+ .card-lbl {
418
+ font-size: 11px;
419
+ color: var(--text3);
420
+ margin-top: 2px;
421
+ line-height: 1.35;
422
+ }
423
+
424
+ .badge {
425
+ flex-shrink: 0;
426
+ font-family: var(--mono);
427
+ font-size: 9px;
428
+ font-weight: 700;
429
+ text-transform: uppercase;
430
+ letter-spacing: .6px;
431
+ padding: 2px 7px;
432
+ border-radius: 20px;
433
+ }
434
+
435
+ /* critical β€” space breaks without this */
436
+ .badge-critical {
437
+ background: rgba(240,80,80,.14);
438
+ color: #f05f5f;
439
+ border: 1px solid rgba(240,80,80,.3);
440
+ }
441
+
442
+ /* credential β€” sensitive key / token */
443
+ .badge-credential {
444
+ background: rgba(220,140,60,.13);
445
+ color: #e09040;
446
+ border: 1px solid rgba(220,140,60,.28);
447
+ }
448
+
449
+ /* feature β€” enables an optional feature */
450
+ .badge-feature {
451
+ background: rgba(70,140,250,.12);
452
+ color: #5a9eff;
453
+ border: 1px solid rgba(70,140,250,.25);
454
+ }
455
+
456
+ /* optional β€” safe to change freely */
457
+ .badge-optional {
458
+ background: rgba(61,214,140,.10);
459
+ color: #3dd68c;
460
+ border: 1px solid rgba(61,214,140,.22);
461
+ }
462
+
463
+ /* advanced β€” power-user, risky if wrong */
464
+ .badge-advanced {
465
+ background: rgba(160,100,230,.12);
466
+ color: #b07ae0;
467
+ border: 1px solid rgba(160,100,230,.25);
468
+ }
469
+
470
+ /* build-time β€” needs HF Space rebuild */
471
+ .badge-build {
472
+ background: rgba(240,185,60,.12);
473
+ color: #e0b030;
474
+ border: 1px solid rgba(240,185,60,.28);
475
+ }
476
+
477
+ /* ── Card inputs ── */
478
+ .card-input { position: relative; }
479
+
480
+ .card-input input[type="text"],
481
+ .card-input input[type="password"],
482
+ .card-input input[type="number"],
483
+ .card-input textarea,
484
+ .card-input select {
485
+ width: 100%;
486
+ background: var(--bg3);
487
+ border: 1px solid var(--border);
488
+ border-radius: var(--r);
489
+ padding: 7px 10px;
490
+ font-family: var(--mono);
491
+ font-size: 11.5px;
492
+ color: var(--text);
493
+ outline: none;
494
+ transition: border-color .15s;
495
+ resize: vertical;
496
+ }
497
+
498
+ .card-input input[type="text"]:focus,
499
+ .card-input input[type="password"]:focus,
500
+ .card-input input[type="number"]:focus,
501
+ .card-input textarea:focus,
502
+ .card-input select:focus {
503
+ border-color: var(--amber);
504
+ }
505
+
506
+ .card-input textarea { min-height: 64px; }
507
+ .card-input select {
508
+ cursor: pointer;
509
+ appearance: none;
510
+ 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");
511
+ background-repeat: no-repeat;
512
+ background-position: right 10px center;
513
+ padding-right: 28px;
514
+ }
515
+
516
+ .card-input optgroup { color: var(--text2); font-weight: 600; }
517
+ .card-input option { color: var(--text); background: var(--bg3); }
518
+
519
+ /* ── Toggle ── */
520
+ .toggle-shell { display: flex; align-items: center; gap: 8px; }
521
+ .tog {
522
+ padding: 5px 14px;
523
+ border-radius: 20px;
524
+ border: 1px solid var(--border2);
525
+ background: var(--bg3);
526
+ color: var(--text3);
527
+ font-family: var(--mono);
528
+ font-size: 11px;
529
+ font-weight: 700;
530
+ cursor: pointer;
531
+ transition: all .18s;
532
+ letter-spacing: .5px;
533
+ }
534
+ .tog.on {
535
+ background: rgba(61,214,140,.15);
536
+ border-color: rgba(61,214,140,.4);
537
+ color: var(--green);
538
+ }
539
+
540
+ /* ── Picker shell ── */
541
+ .picker-shell { display: flex; flex-direction: column; gap: 6px; }
542
+ .picker-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
543
+
544
+ .picker-select {
545
+ flex: 1;
546
+ min-width: 0;
547
+ padding: 6px 28px 6px 8px !important;
548
+ font-size: 11px !important;
549
+ }
550
+
551
+ .mini-btn {
552
+ padding: 5px 9px;
553
+ border-radius: var(--r);
554
+ border: 1px solid var(--border2);
555
+ background: var(--bg3);
556
+ color: var(--text2);
557
+ font-family: var(--mono);
558
+ font-size: 10px;
559
+ font-weight: 600;
560
+ cursor: pointer;
561
+ transition: all .15s;
562
+ white-space: nowrap;
563
+ }
564
+ .mini-btn:hover { background: var(--bg4); color: var(--text); }
565
+
566
+ /* ── Right panel ── */
567
+ .right-panel {
568
+ width: var(--panel-w);
569
+ flex-shrink: 0;
570
+ border-left: 1px solid var(--border);
571
+ background: var(--bg2);
572
+ display: flex;
573
+ flex-direction: column;
574
+ overflow: hidden;
575
+ }
576
+
577
+ .panel-scroll {
578
+ flex: 1;
579
+ overflow-y: auto;
580
+ padding: 16px;
581
+ display: flex;
582
+ flex-direction: column;
583
+ gap: 16px;
584
+ }
585
+
586
+ .panel-scroll::-webkit-scrollbar { width: 4px; }
587
+ .panel-scroll::-webkit-scrollbar-track { background: transparent; }
588
+ .panel-scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
589
+
590
+ /* ── Panel block ── */
591
+ .pblock {
592
+ background: var(--bg3);
593
+ border: 1px solid var(--border);
594
+ border-radius: var(--r2);
595
+ overflow: hidden;
596
+ }
597
+
598
+ .pblock-head {
599
+ display: flex;
600
+ align-items: center;
601
+ justify-content: space-between;
602
+ padding: 10px 14px;
603
+ border-bottom: 1px solid var(--border);
604
+ }
605
+
606
+ .pblock-title {
607
+ font-size: 10.5px;
608
+ font-weight: 700;
609
+ text-transform: uppercase;
610
+ letter-spacing: 1px;
611
+ color: var(--text3);
612
+ display: flex;
613
+ align-items: center;
614
+ gap: 6px;
615
+ }
616
+
617
+ .pblock-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; }
618
+
619
+ .pblock-body textarea,
620
+ .pblock-body input[type="text"] {
621
+ width: 100%;
622
+ background: var(--bg);
623
+ border: 1px solid var(--border);
624
+ border-radius: var(--r);
625
+ padding: 8px 10px;
626
+ font-family: var(--mono);
627
+ font-size: 10.5px;
628
+ color: var(--text2);
629
+ outline: none;
630
+ resize: vertical;
631
+ transition: border-color .15s;
632
+ }
633
+
634
+ .pblock-body textarea:focus,
635
+ .pblock-body input[type="text"]:focus {
636
+ border-color: var(--amber);
637
+ color: var(--text);
638
+ }
639
+
640
+ #importText { min-height: 80px; }
641
+ #bundleOut { min-height: 60px; color: var(--amber2); }
642
+ #envLineOut { font-size: 10px; }
643
+
644
+ .row-btns { display: flex; gap: 6px; flex-wrap: wrap; }
645
+
646
+ /* ── Summary ── */
647
+ #summary {
648
+ font-size: 11.5px;
649
+ color: var(--text2);
650
+ line-height: 1.6;
651
+ }
652
+
653
+ #summary strong {
654
+ font-size: 15px;
655
+ color: var(--amber);
656
+ font-family: var(--mono);
657
+ }
658
+
659
+ .sum-keys {
660
+ margin-top: 8px;
661
+ display: flex;
662
+ flex-wrap: wrap;
663
+ gap: 4px;
664
+ }
665
+
666
+ .sum-key {
667
+ font-family: var(--mono);
668
+ font-size: 9.5px;
669
+ color: var(--text2);
670
+ background: var(--bg4);
671
+ border: 1px solid var(--border2);
672
+ border-radius: 4px;
673
+ padding: 2px 6px;
674
+ }
675
+
676
+ /* ── Custom Env section ── */
677
+ #customSec {
678
+ margin-top: 8px;
679
+ }
680
+
681
+ .custom-row {
682
+ display: flex;
683
+ gap: 8px;
684
+ align-items: center;
685
+ margin-bottom: 8px;
686
+ }
687
+
688
+ .custom-row input {
689
+ flex: 1;
690
+ background: var(--bg3);
691
+ border: 1px solid var(--border);
692
+ border-radius: var(--r);
693
+ padding: 7px 10px;
694
+ font-family: var(--mono);
695
+ font-size: 11px;
696
+ color: var(--text);
697
+ outline: none;
698
+ transition: border-color .15s;
699
+ min-width: 0;
700
+ }
701
+
702
+ .custom-row input:focus { border-color: var(--amber); }
703
+ .custom-row input:first-child { flex: 0 0 40%; }
704
+
705
+ /* ── Toast ── */
706
+ #toast {
707
+ position: fixed;
708
+ bottom: 24px;
709
+ left: 50%;
710
+ transform: translateX(-50%) translateY(20px);
711
+ background: var(--bg4);
712
+ border: 1px solid var(--border2);
713
+ color: var(--amber);
714
+ font-family: var(--mono);
715
+ font-size: 12px;
716
+ font-weight: 600;
717
+ padding: 9px 20px;
718
+ border-radius: 30px;
719
+ z-index: 9999;
720
+ opacity: 0;
721
+ transition: opacity .2s, transform .2s;
722
+ pointer-events: none;
723
+ box-shadow: 0 8px 32px rgba(0,0,0,.5);
724
+ }
725
+
726
+ #toast.show {
727
+ opacity: 1;
728
+ transform: translateX(-50%) translateY(0);
729
+ }
730
+
731
+ /* ── Scrollbar global ── */
732
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
733
+ ::-webkit-scrollbar-track { background: transparent; }
734
+ ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
735
+
736
+ /* ── Responsive ── */
737
+ @media (max-width: 900px) {
738
+ :root { --panel-w: 280px; --sidebar-w: 180px; }
739
+ }
740
+
741
+ @media (max-width: 700px) {
742
+ .right-panel { display: none; }
743
+ :root { --sidebar-w: 160px; }
744
+ }
745
+
746
+ @media (max-width: 520px) {
747
+ .sidebar-wrap { display: none; }
748
+ .topbar-divider, .topbar-title { display: none; }
749
+ }
750
+
751
+ /* ── Tag Legend (collapsible) ── */
752
+ .tag-legend {
753
+ margin-bottom: 14px;
754
+ background: var(--bg2);
755
+ border: 1px solid var(--border);
756
+ border-radius: var(--r);
757
+ overflow: hidden;
758
+ }
759
+ .legend-summary {
760
+ display: flex;
761
+ align-items: center;
762
+ gap: 10px;
763
+ padding: 7px 12px;
764
+ cursor: pointer;
765
+ list-style: none;
766
+ user-select: none;
767
+ outline: none;
768
+ }
769
+ .legend-summary::-webkit-details-marker { display: none; }
770
+ .legend-chips { display: flex; gap: 5px; flex-wrap: wrap; flex: 1; }
771
+ .legend-hint {
772
+ font-size: 10px;
773
+ color: var(--text3);
774
+ white-space: nowrap;
775
+ flex-shrink: 0;
776
+ }
777
+ .tag-legend[open] .legend-hint { opacity: 0; }
778
+ .legend-body {
779
+ padding: 8px 12px 10px;
780
+ border-top: 1px solid var(--border);
781
+ display: flex;
782
+ flex-direction: column;
783
+ gap: 6px;
784
+ }
785
+ .legend-row {
786
+ display: flex;
787
+ align-items: center;
788
+ gap: 10px;
789
+ font-size: 11px;
790
+ color: var(--text2);
791
+ }
792
+ .legend-row .badge { flex-shrink: 0; width: 74px; text-align: center; }
793
+ .legend-tip {
794
+ font-size: 9.5px;
795
+ color: var(--text3);
796
+ margin-top: 4px;
797
+ padding-top: 6px;
798
+ border-top: 1px solid var(--border);
799
+ }
800
+ </style>
801
+ </head>
802
+
803
+ <body>
804
+
805
+ <!-- ── Topbar ── -->
806
+ <header class="topbar">
807
+ <div class="topbar-logo">
808
+ <svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
809
+ <rect width="64" height="64" rx="14" fill="#1a1f2e"/>
810
+ <text x="10" y="46" font-size="40" font-family="monospace">πŸ€—</text>
811
+ <circle cx="46" cy="20" r="10" fill="#f5a623"/>
812
+ <text x="40" y="25" font-size="14" font-family="monospace" fill="#0b0c0f">⚑</text>
813
+ </svg>
814
+ <span class="topbar-wordmark">Hugging<em>Claw</em></span>
815
+ </div>
816
+ <div class="topbar-divider"></div>
817
+ <span class="topbar-title">ENV Builder</span>
818
+ <div class="topbar-spacer"></div>
819
+ <span class="topbar-pill">v2025</span>
820
+ </header>
821
+
822
+ <!-- ── Layout ── -->
823
+ <div class="layout">
824
+
825
+ <!-- ── Sidebar ── -->
826
+ <aside class="sidebar-wrap">
827
+ <div class="sidebar-scroll">
828
+ <div id="sidebar"></div>
829
+ </div>
830
+ </aside>
831
+
832
+ <!-- ── Main ── -->
833
+ <main class="main">
834
+
835
+ <!-- toolbar -->
836
+ <div class="toolbar">
837
+ <div class="search-wrap">
838
+ <span class="search-icon">βŒ•</span>
839
+ <input id="search" type="text" placeholder="Search variables…" autocomplete="off" spellcheck="false">
840
+ </div>
841
+
842
+ <div class="tb-sep"></div>
843
+
844
+ <button id="selectCommon" class="btn">β˜… Common</button>
845
+ <button id="selectVisible" class="btn">β˜‘ Visible</button>
846
+ <button id="clearAll" class="btn btn-ghost">βœ• Clear</button>
847
+ </div>
848
+
849
+ <!-- content row -->
850
+ <div class="content-wrap">
851
+
852
+ <!-- sections -->
853
+ <div class="sections-scroll">
854
+ <!-- Tag Legend (compact collapsible) -->
855
+ <details class="tag-legend" id="tagLegend">
856
+ <summary class="legend-summary">
857
+ <span class="legend-chips">
858
+ <span class="badge badge-critical">critical</span>
859
+ <span class="badge badge-credential">credential</span>
860
+ <span class="badge badge-feature">feature</span>
861
+ <span class="badge badge-optional">optional</span>
862
+ <span class="badge badge-advanced">advanced</span>
863
+ <span class="badge badge-build">build-time</span>
864
+ </span>
865
+ <span class="legend-hint">what do these mean?</span>
866
+ </summary>
867
+ <div class="legend-body">
868
+ <div class="legend-row"><span class="badge badge-critical">critical</span><span>Space won't work without this</span></div>
869
+ <div class="legend-row"><span class="badge badge-credential">credential</span><span>Sensitive key or token β€” never share</span></div>
870
+ <div class="legend-row"><span class="badge badge-feature">feature</span><span>Enables a specific optional feature</span></div>
871
+ <div class="legend-row"><span class="badge badge-optional">optional</span><span>Safe to change β€” nothing breaks</span></div>
872
+ <div class="legend-row"><span class="badge badge-advanced">advanced</span><span>Power-user setting β€” wrong value causes issues</span></div>
873
+ <div class="legend-row"><span class="badge badge-build">build-time</span><span>Needs HF Space rebuild to fully take effect</span></div>
874
+ <div class="legend-tip">πŸ’‘ Type a tag name in search to filter by it</div>
875
+ </div>
876
+ </details>
877
+ <div id="sections"></div>
878
+
879
+ <!-- Custom Env section -->
880
+ <div id="customSec" class="sec" data-section="Custom Env">
881
+ <div class="sec-header">
882
+ <span class="sec-icon">πŸ”§</span>
883
+ <span class="sec-title">Custom Env</span>
884
+ <div class="sec-line"></div>
885
+ </div>
886
+ <div id="customRows"></div>
887
+ <button id="addCustom" class="btn" style="margin-top:6px;">+ Add variable</button>
888
+ </div>
889
+ </div>
890
+
891
+ <!-- right panel -->
892
+ <aside class="right-panel">
893
+ <div class="panel-scroll">
894
+
895
+ <!-- Summary -->
896
+ <div class="pblock">
897
+ <div class="pblock-head">
898
+ <span class="pblock-title">πŸ“Š Summary</span>
899
+ </div>
900
+ <div class="pblock-body">
901
+ <div id="summary">No variables selected yet.</div>
902
+ </div>
903
+ </div>
904
+
905
+ <!-- Output -->
906
+ <div class="pblock">
907
+ <div class="pblock-head">
908
+ <span class="pblock-title">πŸ“¦ Bundle Output</span>
909
+ </div>
910
+ <div class="pblock-body">
911
+ <textarea id="bundleOut" placeholder="Your encoded bundle will appear here…" readonly spellcheck="false"></textarea>
912
+ <input type="text" id="envLineOut" placeholder="HUGGINGCLAW_ENV_BUNDLE=…" readonly spellcheck="false">
913
+ <div class="row-btns">
914
+ <button id="copyBundle" class="btn btn-amber">⎘ Bundle</button>
915
+ <button id="copyEnvLine" class="btn">⎘ Env Line</button>
916
+ <button id="copyJson" class="btn">⎘ JSON</button>
917
+ <button id="applyBundle" class="btn btn-ghost">β†Ί Apply</button>
918
+ </div>
919
+ </div>
920
+ </div>
921
+
922
+ <!-- Import -->
923
+ <div class="pblock">
924
+ <div class="pblock-head">
925
+ <span class="pblock-title">πŸ“₯ Import</span>
926
+ </div>
927
+ <div class="pblock-body">
928
+ <textarea id="importText" placeholder="Paste .env, JSON, or HUGGINGCLAW_ENV_BUNDLE=… here" spellcheck="false"></textarea>
929
+ <button id="applyImport" class="btn btn-amber" style="width:100%;">↓ Import & Apply</button>
930
+ </div>
931
+ </div>
932
+
933
+ </div>
934
+ </aside>
935
+
936
+ </div><!-- /content-wrap -->
937
+ </main>
938
+ </div><!-- /layout -->
939
+
940
+ <!-- Toast -->
941
+ <div id="toast">Copied βœ“</div>
942
+
943
+ <!-- env-builder.js provides MODEL_CATALOGS, FIELDS, ICONS and all logic -->
944
+ <script src="env-builder.js"></script>
945
+
946
+ </body>
947
+ </html>
env-builder.js ADDED
@@ -0,0 +1,2527 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "tag": "critical"
429
+ },
430
+ {
431
+ "g": "Core",
432
+ "icon": "⚑",
433
+ "k": "LLM_API_KEY",
434
+ "lbl": "Primary provider API key",
435
+ "type": "password",
436
+ "ph": "sk-...",
437
+ "common": 1,
438
+ "tag": "credential"
439
+ },
440
+ {
441
+ "g": "Core",
442
+ "icon": "⚑",
443
+ "k": "GATEWAY_TOKEN",
444
+ "lbl": "Control UI gateway token",
445
+ "type": "password",
446
+ "common": 1,
447
+ "tag": "critical"
448
+ },
449
+ {
450
+ "g": "Core",
451
+ "icon": "⚑",
452
+ "k": "OPENCLAW_PASSWORD",
453
+ "lbl": "Optional password auth",
454
+ "type": "password",
455
+ "tag": "credential"
456
+ },
457
+ {
458
+ "g": "Core",
459
+ "icon": "⚑",
460
+ "k": "OPENCLAW_VERSION",
461
+ "lbl": "Pin OpenClaw version",
462
+ "type": "text",
463
+ "ph": "latest",
464
+ "tag": "optional"
465
+ },
466
+ {
467
+ "g": "Plugins",
468
+ "icon": "⚑",
469
+ "k": "LLM_API_KEY_FALLBACK_ENABLED",
470
+ "lbl": "Allow global LLM_API_KEY fallback",
471
+ "type": "toggle",
472
+ "ph": "true",
473
+ "tag": "advanced"
474
+ },
475
+ {
476
+ "g": "Startup",
477
+ "icon": "⚑",
478
+ "k": "DEV_MODE",
479
+ "lbl": "Enable dev mode",
480
+ "type": "toggle",
481
+ "ph": "false",
482
+ "common": 1,
483
+ "tag": "build"
484
+ },
485
+ {
486
+ "g": "Startup",
487
+ "icon": "⚑",
488
+ "k": "HUGGINGCLAW_JUPYTER_ENABLED",
489
+ "lbl": "Enable Jupyter terminal",
490
+ "type": "toggle",
491
+ "ph": "false",
492
+ "common": 1,
493
+ "tag": "feature"
494
+ },
495
+ {
496
+ "g": "DevData",
497
+ "icon": "⚑",
498
+ "k": "DEVDATA",
499
+ "lbl": "DevData switch",
500
+ "type": "toggle",
501
+ "ph": "on",
502
+ "common": 1,
503
+ "tag": "feature"
504
+ },
505
+ {
506
+ "g": "DevData",
507
+ "icon": "⚑",
508
+ "k": "DEVDATA_DATASET_NAME",
509
+ "lbl": "DevData dataset name",
510
+ "type": "text",
511
+ "ph": "huggingclaw-devdata",
512
+ "common": 1,
513
+ "tag": "feature"
514
+ },
515
+ {
516
+ "g": "DevData",
517
+ "icon": "⚑",
518
+ "k": "DEVDATA_SYNC_INTERVAL",
519
+ "lbl": "DevData sync interval (seconds)",
520
+ "type": "number",
521
+ "ph": "180",
522
+ "tag": "advanced"
523
+ },
524
+ {
525
+ "g": "WhatsApp",
526
+ "icon": "⚑",
527
+ "k": "WHATSAPP_ENABLED",
528
+ "lbl": "Enable WhatsApp pairing",
529
+ "type": "toggle",
530
+ "ph": "false",
531
+ "common": 1,
532
+ "tag": "feature"
533
+ },
534
+ {
535
+ "g": "Startup",
536
+ "icon": "⚑",
537
+ "k": "HUGGINGCLAW_CAPTURE_DISABLE",
538
+ "lbl": "Disable capture wrapper",
539
+ "type": "toggle",
540
+ "ph": "false",
541
+ "tag": "advanced"
542
+ },
543
+ {
544
+ "g": "Startup",
545
+ "icon": "⚑",
546
+ "k": "HUGGINGCLAW_STARTUP_STRICT",
547
+ "lbl": "Stop on startup failure",
548
+ "type": "toggle",
549
+ "ph": "false",
550
+ "tag": "advanced"
551
+ },
552
+ {
553
+ "g": "Startup",
554
+ "icon": "⚑",
555
+ "k": "HUGGINGCLAW_RUN",
556
+ "lbl": "Startup command (one-liner)",
557
+ "type": "textarea",
558
+ "tag": "optional"
559
+ },
560
+ {
561
+ "g": "Startup",
562
+ "icon": "⚑",
563
+ "k": "HUGGINGCLAW_STARTUP_COMMANDS",
564
+ "lbl": "Multiline startup commands",
565
+ "type": "textarea",
566
+ "tag": "optional"
567
+ },
568
+ {
569
+ "g": "Startup",
570
+ "icon": "⚑",
571
+ "k": "HUGGINGCLAW_STARTUP_SCRIPT",
572
+ "lbl": "Startup shell script",
573
+ "type": "textarea",
574
+ "tag": "optional"
575
+ },
576
+ {
577
+ "g": "Startup",
578
+ "icon": "⚑",
579
+ "k": "HUGGINGCLAW_STARTUP_SCRIPT_B64",
580
+ "lbl": "Startup script (base64)",
581
+ "type": "textarea",
582
+ "tag": "optional"
583
+ },
584
+ {
585
+ "g": "Startup",
586
+ "icon": "⚑",
587
+ "k": "HUGGINGCLAW_APT_PACKAGES",
588
+ "lbl": "APT packages to install",
589
+ "type": "textarea",
590
+ "tag": "optional"
591
+ },
592
+ {
593
+ "g": "Startup",
594
+ "icon": "⚑",
595
+ "k": "HUGGINGCLAW_PIP_PACKAGES",
596
+ "lbl": "Pip packages to install",
597
+ "type": "textarea",
598
+ "tag": "optional"
599
+ },
600
+ {
601
+ "g": "Startup",
602
+ "icon": "⚑",
603
+ "k": "HUGGINGCLAW_NPM_PACKAGES",
604
+ "lbl": "NPM packages to install",
605
+ "type": "textarea",
606
+ "tag": "optional"
607
+ },
608
+ {
609
+ "g": "Startup",
610
+ "icon": "⚑",
611
+ "k": "HUGGINGCLAW_OPENCLAW_PLUGINS",
612
+ "lbl": "OpenClaw plugins to load",
613
+ "type": "textarea",
614
+ "tag": "optional"
615
+ },
616
+ {
617
+ "g": "Network",
618
+ "icon": "⚑",
619
+ "k": "ALLOWED_ORIGINS",
620
+ "lbl": "Allowed CORS origins",
621
+ "type": "textarea",
622
+ "tag": "advanced"
623
+ },
624
+ {
625
+ "g": "Network",
626
+ "icon": "⚑",
627
+ "k": "TRUSTED_PROXIES",
628
+ "lbl": "Trusted proxy CIDRs",
629
+ "type": "textarea",
630
+ "tag": "advanced"
631
+ },
632
+ {
633
+ "g": "Network",
634
+ "icon": "⚑",
635
+ "k": "WEBHOOK_URL",
636
+ "lbl": "Webhook URL",
637
+ "type": "text",
638
+ "ph": "https://...",
639
+ "tag": "feature"
640
+ },
641
+ {
642
+ "g": "Gateway",
643
+ "icon": "⚑",
644
+ "k": "GATEWAY_MAX_RESTARTS",
645
+ "lbl": "Gateway max restarts",
646
+ "type": "number",
647
+ "ph": "10",
648
+ "tag": "advanced"
649
+ },
650
+ {
651
+ "g": "Gateway",
652
+ "icon": "⚑",
653
+ "k": "GATEWAY_READY_TIMEOUT",
654
+ "lbl": "Gateway ready timeout",
655
+ "type": "number",
656
+ "ph": "90",
657
+ "tag": "advanced"
658
+ },
659
+ {
660
+ "g": "Gateway",
661
+ "icon": "⚑",
662
+ "k": "GATEWAY_RESTART_DELAY",
663
+ "lbl": "Gateway restart delay",
664
+ "type": "number",
665
+ "ph": "5",
666
+ "tag": "advanced"
667
+ },
668
+ {
669
+ "g": "Gateway",
670
+ "icon": "⚑",
671
+ "k": "GATEWAY_VERBOSE",
672
+ "lbl": "Verbose gateway logs",
673
+ "type": "toggle",
674
+ "ph": "false",
675
+ "tag": "advanced"
676
+ },
677
+ {
678
+ "g": "Logging",
679
+ "icon": "⚑",
680
+ "k": "OPENCLAW_CONSOLE_LOG_LEVEL",
681
+ "lbl": "Console log level",
682
+ "type": "select",
683
+ "options": [
684
+ "debug",
685
+ "info",
686
+ "warn",
687
+ "error"
688
+ ],
689
+ "ph": "info",
690
+ "tag": "optional"
691
+ },
692
+ {
693
+ "g": "Logging",
694
+ "icon": "⚑",
695
+ "k": "OPENCLAW_FILE_LOG_LEVEL",
696
+ "lbl": "File log level",
697
+ "type": "select",
698
+ "options": [
699
+ "debug",
700
+ "info",
701
+ "warn",
702
+ "error"
703
+ ],
704
+ "ph": "info",
705
+ "tag": "optional"
706
+ },
707
+ {
708
+ "g": "Logging",
709
+ "icon": "⚑",
710
+ "k": "OPENCLAW_CONSOLE_LOG_STYLE",
711
+ "lbl": "Console log style",
712
+ "type": "select",
713
+ "options": [
714
+ "pretty",
715
+ "json",
716
+ "compact"
717
+ ],
718
+ "ph": "pretty",
719
+ "tag": "optional"
720
+ },
721
+ {
722
+ "g": "Plugins",
723
+ "icon": "⚑",
724
+ "k": "BROWSER_PLUGIN_MODE",
725
+ "lbl": "Browser plugin mode",
726
+ "type": "select",
727
+ "options": [
728
+ "auto",
729
+ "enabled",
730
+ "disabled"
731
+ ],
732
+ "ph": "auto",
733
+ "tag": "feature"
734
+ },
735
+ {
736
+ "g": "Plugins",
737
+ "icon": "⚑",
738
+ "k": "ACP_PLUGIN_MODE",
739
+ "lbl": "ACP plugin mode",
740
+ "type": "select",
741
+ "options": [
742
+ "auto",
743
+ "enabled",
744
+ "disabled"
745
+ ],
746
+ "ph": "auto",
747
+ "tag": "feature"
748
+ },
749
+ {
750
+ "g": "Cloudflare",
751
+ "icon": "⚑",
752
+ "k": "CLOUDFLARE_PROXY_DEBUG",
753
+ "lbl": "Cloudflare proxy debug",
754
+ "type": "toggle",
755
+ "ph": "false",
756
+ "tag": "advanced"
757
+ },
758
+ {
759
+ "g": "Cloudflare",
760
+ "icon": "⚑",
761
+ "k": "CLOUDFLARE_KEEPALIVE_ENABLED",
762
+ "lbl": "Enable keep-awake worker",
763
+ "type": "toggle",
764
+ "ph": "true",
765
+ "tag": "feature"
766
+ },
767
+ {
768
+ "g": "Cloudflare",
769
+ "icon": "⚑",
770
+ "k": "CLOUDFLARE_PROXY_URL",
771
+ "lbl": "Proxy worker URL",
772
+ "type": "text",
773
+ "ph": "https://your-proxy.workers.dev",
774
+ "common": 1,
775
+ "tag": "feature"
776
+ },
777
+ {
778
+ "g": "Cloudflare",
779
+ "icon": "⚑",
780
+ "k": "CLOUDFLARE_PROXY_SECRET",
781
+ "lbl": "Proxy shared secret",
782
+ "type": "password",
783
+ "tag": "credential"
784
+ },
785
+ {
786
+ "g": "Cloudflare",
787
+ "icon": "⚑",
788
+ "k": "CLOUDFLARE_PROXY_DOMAINS",
789
+ "lbl": "Extra domains to proxy",
790
+ "type": "textarea",
791
+ "ph": "api.sendgrid.com,slack.com",
792
+ "tag": "advanced"
793
+ },
794
+ {
795
+ "g": "Cloudflare",
796
+ "icon": "⚑",
797
+ "k": "CLOUDFLARE_WORKERS_TOKEN",
798
+ "lbl": "Workers API token",
799
+ "type": "password",
800
+ "common": 1,
801
+ "tag": "credential"
802
+ },
803
+ {
804
+ "g": "Core",
805
+ "icon": "⚑",
806
+ "k": "HF_USERNAME",
807
+ "lbl": "Hugging Face username",
808
+ "type": "text",
809
+ "common": 1,
810
+ "tag": "optional"
811
+ },
812
+ {
813
+ "g": "Core",
814
+ "icon": "⚑",
815
+ "k": "HF_TOKEN",
816
+ "lbl": "HF write token",
817
+ "type": "password",
818
+ "common": 1,
819
+ "tag": "credential"
820
+ },
821
+ {
822
+ "g": "Core",
823
+ "icon": "⚑",
824
+ "k": "BACKUP_DATASET_NAME",
825
+ "lbl": "Backup dataset name",
826
+ "type": "text",
827
+ "ph": "huggingclaw-backup",
828
+ "common": 1,
829
+ "tag": "optional"
830
+ },
831
+ {
832
+ "g": "Core",
833
+ "icon": "⚑",
834
+ "k": "SYNC_INTERVAL",
835
+ "lbl": "Sync interval (seconds)",
836
+ "type": "number",
837
+ "ph": "180",
838
+ "common": 1,
839
+ "tag": "advanced"
840
+ },
841
+ {
842
+ "g": "Core",
843
+ "icon": "⚑",
844
+ "k": "JUPYTER_TOKEN",
845
+ "lbl": "Jupyter access token",
846
+ "type": "password",
847
+ "ph": "huggingface",
848
+ "common": 1,
849
+ "tag": "credential"
850
+ },
851
+ {
852
+ "g": "Core",
853
+ "icon": "⚑",
854
+ "k": "KEEP_ALIVE_INTERVAL",
855
+ "lbl": "Keep-alive ping interval (seconds)",
856
+ "type": "number",
857
+ "ph": "300",
858
+ "common": 1,
859
+ "tag": "advanced"
860
+ },
861
+ {
862
+ "g": "Core",
863
+ "icon": "⚑",
864
+ "k": "OPENCLAW_DISABLE_BONJOUR",
865
+ "lbl": "Disable Bonjour/mDNS discovery",
866
+ "type": "toggle",
867
+ "ph": "false",
868
+ "tag": "advanced"
869
+ },
870
+ {
871
+ "g": "Core",
872
+ "icon": "⚑",
873
+ "k": "OPENCLAW_RUNTIME_VERSION",
874
+ "lbl": "Pin runtime version",
875
+ "type": "text",
876
+ "ph": "latest",
877
+ "tag": "advanced"
878
+ },
879
+ {
880
+ "g": "Core",
881
+ "icon": "⚑",
882
+ "k": "OPENCLAW_DISPLAY_VERSION",
883
+ "lbl": "Display version label",
884
+ "type": "text",
885
+ "ph": "",
886
+ "tag": "optional"
887
+ },
888
+ {
889
+ "g": "Integrations",
890
+ "icon": "πŸ”Œ",
891
+ "k": "CLOUDFLARE_ACCOUNT_ID",
892
+ "lbl": "Cloudflare account ID",
893
+ "type": "text",
894
+ "ph": "account-id",
895
+ "tag": "feature"
896
+ },
897
+ {
898
+ "g": "Integrations",
899
+ "icon": "πŸ”Œ",
900
+ "k": "CLOUDFLARE_WORKER_NAME",
901
+ "lbl": "Outbound proxy worker name",
902
+ "type": "text",
903
+ "ph": "huggingclaw-proxy",
904
+ "tag": "feature"
905
+ },
906
+ {
907
+ "g": "Integrations",
908
+ "icon": "πŸ”Œ",
909
+ "k": "CLOUDFLARE_KEEPALIVE_URL",
910
+ "lbl": "Keepalive worker URL",
911
+ "type": "text",
912
+ "ph": "https://your-worker.workers.dev",
913
+ "tag": "feature"
914
+ },
915
+ {
916
+ "g": "Integrations",
917
+ "icon": "πŸ”Œ",
918
+ "k": "CLOUDFLARE_KEEPALIVE_WORKER_NAME",
919
+ "lbl": "Keepalive worker name",
920
+ "type": "text",
921
+ "ph": "huggingclaw-keepalive",
922
+ "tag": "feature"
923
+ },
924
+ {
925
+ "g": "Integrations",
926
+ "icon": "πŸ”Œ",
927
+ "k": "CLOUDFLARE_KEEPALIVE_CRON",
928
+ "lbl": "Keepalive cron schedule",
929
+ "type": "text",
930
+ "ph": "*/5 * * * *",
931
+ "tag": "advanced"
932
+ },
933
+ {
934
+ "g": "Integrations",
935
+ "icon": "πŸ”Œ",
936
+ "k": "TELEGRAM_API_ROOT",
937
+ "lbl": "Telegram API root override",
938
+ "type": "text",
939
+ "ph": "https://api.telegram.org",
940
+ "tag": "advanced"
941
+ },
942
+ {
943
+ "g": "Runtime",
944
+ "icon": "βš™οΈ",
945
+ "k": "OPENCLAW_CONFIG_WATCH_INTERVAL",
946
+ "lbl": "Config watch interval (seconds)",
947
+ "type": "number",
948
+ "ph": "1",
949
+ "tag": "advanced"
950
+ },
951
+ {
952
+ "g": "Runtime",
953
+ "icon": "βš™οΈ",
954
+ "k": "OPENCLAW_CONFIG_SETTLE_SECONDS",
955
+ "lbl": "Config settle window (seconds)",
956
+ "type": "number",
957
+ "ph": "3",
958
+ "tag": "advanced"
959
+ },
960
+ {
961
+ "g": "Runtime",
962
+ "icon": "βš™οΈ",
963
+ "k": "JUPYTER_ROOT_DIR",
964
+ "lbl": "Jupyter root directory",
965
+ "type": "text",
966
+ "ph": "/home/node",
967
+ "tag": "advanced"
968
+ },
969
+ {
970
+ "g": "Backup",
971
+ "icon": "πŸ’Ύ",
972
+ "k": "WORKSPACE_GIT_USER",
973
+ "lbl": "Workspace git author email",
974
+ "type": "text",
975
+ "ph": "openclaw@example.com",
976
+ "tag": "optional"
977
+ },
978
+ {
979
+ "g": "Backup",
980
+ "icon": "πŸ’Ύ",
981
+ "k": "WORKSPACE_GIT_NAME",
982
+ "lbl": "Workspace git author name",
983
+ "type": "text",
984
+ "ph": "OpenClaw Bot",
985
+ "tag": "optional"
986
+ },
987
+ {
988
+ "g": "Provider Keys",
989
+ "icon": "πŸ”‘",
990
+ "k": "ANTHROPIC_API_KEY",
991
+ "lbl": "Anthropic (Claude)",
992
+ "type": "password",
993
+ "common": 0,
994
+ "tag": "credential"
995
+ },
996
+ {
997
+ "g": "Provider Keys",
998
+ "icon": "πŸ”‘",
999
+ "k": "OPENAI_API_KEY",
1000
+ "lbl": "OpenAI (GPT)",
1001
+ "type": "password",
1002
+ "common": 0,
1003
+ "tag": "credential"
1004
+ },
1005
+ {
1006
+ "g": "Provider Keys",
1007
+ "icon": "πŸ”‘",
1008
+ "k": "GOOGLE_API_KEY",
1009
+ "lbl": "Google AI Studio",
1010
+ "type": "password",
1011
+ "common": 0,
1012
+ "tag": "credential"
1013
+ },
1014
+ {
1015
+ "g": "Provider Keys",
1016
+ "icon": "πŸ”‘",
1017
+ "k": "GEMINI_API_KEY",
1018
+ "lbl": "Google Gemini",
1019
+ "type": "password",
1020
+ "common": 0,
1021
+ "tag": "credential"
1022
+ },
1023
+ {
1024
+ "g": "Provider Keys",
1025
+ "icon": "πŸ”‘",
1026
+ "k": "DEEPSEEK_API_KEY",
1027
+ "lbl": "DeepSeek",
1028
+ "type": "password",
1029
+ "common": 0,
1030
+ "tag": "credential"
1031
+ },
1032
+ {
1033
+ "g": "Provider Keys",
1034
+ "icon": "πŸ”‘",
1035
+ "k": "OPENROUTER_API_KEY",
1036
+ "lbl": "OpenRouter",
1037
+ "type": "password",
1038
+ "common": 1,
1039
+ "tag": "credential"
1040
+ },
1041
+ {
1042
+ "g": "Provider Keys",
1043
+ "icon": "πŸ”‘",
1044
+ "k": "OPENCODE_API_KEY",
1045
+ "lbl": "OpenCode",
1046
+ "type": "password",
1047
+ "common": 0,
1048
+ "tag": "credential"
1049
+ },
1050
+ {
1051
+ "g": "Provider Keys",
1052
+ "icon": "πŸ”‘",
1053
+ "k": "KILOCODE_API_KEY",
1054
+ "lbl": "KiloCode",
1055
+ "type": "password",
1056
+ "common": 0,
1057
+ "tag": "credential"
1058
+ },
1059
+ {
1060
+ "g": "Provider Keys",
1061
+ "icon": "πŸ”‘",
1062
+ "k": "ZAI_API_KEY",
1063
+ "lbl": "Z.ai / GLM",
1064
+ "type": "password",
1065
+ "common": 0,
1066
+ "tag": "credential"
1067
+ },
1068
+ {
1069
+ "g": "Provider Keys",
1070
+ "icon": "πŸ”‘",
1071
+ "k": "MOONSHOT_API_KEY",
1072
+ "lbl": "Moonshot / Kimi",
1073
+ "type": "password",
1074
+ "common": 0,
1075
+ "tag": "credential"
1076
+ },
1077
+ {
1078
+ "g": "Provider Keys",
1079
+ "icon": "πŸ”‘",
1080
+ "k": "MINIMAX_API_KEY",
1081
+ "lbl": "MiniMax",
1082
+ "type": "password",
1083
+ "common": 0,
1084
+ "tag": "credential"
1085
+ },
1086
+ {
1087
+ "g": "Provider Keys",
1088
+ "icon": "πŸ”‘",
1089
+ "k": "XIAOMI_API_KEY",
1090
+ "lbl": "Xiaomi / MiMo",
1091
+ "type": "password",
1092
+ "common": 0,
1093
+ "tag": "credential"
1094
+ },
1095
+ {
1096
+ "g": "Provider Keys",
1097
+ "icon": "πŸ”‘",
1098
+ "k": "VOLCANO_ENGINE_API_KEY",
1099
+ "lbl": "Volcengine / Doubao",
1100
+ "type": "password",
1101
+ "common": 0,
1102
+ "tag": "credential"
1103
+ },
1104
+ {
1105
+ "g": "Provider Keys",
1106
+ "icon": "πŸ”‘",
1107
+ "k": "BYTEPLUS_API_KEY",
1108
+ "lbl": "BytePlus",
1109
+ "type": "password",
1110
+ "common": 0,
1111
+ "tag": "credential"
1112
+ },
1113
+ {
1114
+ "g": "Provider Keys",
1115
+ "icon": "πŸ”‘",
1116
+ "k": "MISTRAL_API_KEY",
1117
+ "lbl": "Mistral",
1118
+ "type": "password",
1119
+ "common": 0,
1120
+ "tag": "credential"
1121
+ },
1122
+ {
1123
+ "g": "Provider Keys",
1124
+ "icon": "πŸ”‘",
1125
+ "k": "XAI_API_KEY",
1126
+ "lbl": "xAI (Grok)",
1127
+ "type": "password",
1128
+ "common": 0,
1129
+ "tag": "credential"
1130
+ },
1131
+ {
1132
+ "g": "Provider Keys",
1133
+ "icon": "πŸ”‘",
1134
+ "k": "NVIDIA_API_KEY",
1135
+ "lbl": "NVIDIA",
1136
+ "type": "password",
1137
+ "common": 0,
1138
+ "tag": "credential"
1139
+ },
1140
+ {
1141
+ "g": "Provider Keys",
1142
+ "icon": "πŸ”‘",
1143
+ "k": "GROQ_API_KEY",
1144
+ "lbl": "Groq",
1145
+ "type": "password",
1146
+ "common": 0,
1147
+ "tag": "credential"
1148
+ },
1149
+ {
1150
+ "g": "Provider Keys",
1151
+ "icon": "πŸ”‘",
1152
+ "k": "COHERE_API_KEY",
1153
+ "lbl": "Cohere",
1154
+ "type": "password",
1155
+ "common": 0,
1156
+ "tag": "credential"
1157
+ },
1158
+ {
1159
+ "g": "Provider Keys",
1160
+ "icon": "πŸ”‘",
1161
+ "k": "TOGETHER_API_KEY",
1162
+ "lbl": "Together AI",
1163
+ "type": "password",
1164
+ "common": 0,
1165
+ "tag": "credential"
1166
+ },
1167
+ {
1168
+ "g": "Provider Keys",
1169
+ "icon": "πŸ”‘",
1170
+ "k": "CEREBRAS_API_KEY",
1171
+ "lbl": "Cerebras",
1172
+ "type": "password",
1173
+ "common": 0,
1174
+ "tag": "credential"
1175
+ },
1176
+ {
1177
+ "g": "Provider Keys",
1178
+ "icon": "πŸ”‘",
1179
+ "k": "QIANFAN_API_KEY",
1180
+ "lbl": "Qianfan",
1181
+ "type": "password",
1182
+ "common": 0,
1183
+ "tag": "credential"
1184
+ },
1185
+ {
1186
+ "g": "Provider Keys",
1187
+ "icon": "πŸ”‘",
1188
+ "k": "MODELSTUDIO_API_KEY",
1189
+ "lbl": "ModelStudio",
1190
+ "type": "password",
1191
+ "common": 0,
1192
+ "tag": "credential"
1193
+ },
1194
+ {
1195
+ "g": "Provider Keys",
1196
+ "icon": "πŸ”‘",
1197
+ "k": "KIMI_API_KEY",
1198
+ "lbl": "Kimi",
1199
+ "type": "password",
1200
+ "common": 0,
1201
+ "tag": "credential"
1202
+ },
1203
+ {
1204
+ "g": "Provider Keys",
1205
+ "icon": "πŸ”‘",
1206
+ "k": "HUGGINGFACE_HUB_TOKEN",
1207
+ "lbl": "Hugging Face token",
1208
+ "type": "password",
1209
+ "common": 0,
1210
+ "tag": "credential"
1211
+ },
1212
+ {
1213
+ "g": "Provider Keys",
1214
+ "icon": "πŸ”‘",
1215
+ "k": "COPILOT_GITHUB_TOKEN",
1216
+ "lbl": "GitHub Copilot",
1217
+ "type": "password",
1218
+ "common": 0,
1219
+ "tag": "credential"
1220
+ },
1221
+ {
1222
+ "g": "Provider Keys",
1223
+ "icon": "πŸ”‘",
1224
+ "k": "VENICE_API_KEY",
1225
+ "lbl": "Venice",
1226
+ "type": "password",
1227
+ "common": 0,
1228
+ "tag": "credential"
1229
+ },
1230
+ {
1231
+ "g": "Provider Keys",
1232
+ "icon": "πŸ”‘",
1233
+ "k": "SYNTHETIC_API_KEY",
1234
+ "lbl": "Synthetic",
1235
+ "type": "password",
1236
+ "common": 0,
1237
+ "tag": "credential"
1238
+ },
1239
+ {
1240
+ "g": "Provider Keys",
1241
+ "icon": "πŸ”‘",
1242
+ "k": "AI_GATEWAY_API_KEY",
1243
+ "lbl": "AI Gateway",
1244
+ "type": "password",
1245
+ "common": 0,
1246
+ "tag": "credential"
1247
+ },
1248
+ {
1249
+ "g": "Provider Keys",
1250
+ "icon": "πŸ”‘",
1251
+ "k": "CLOUDFLARE_API_TOKEN",
1252
+ "lbl": "Cloudflare API token",
1253
+ "type": "password",
1254
+ "common": 0,
1255
+ "tag": "credential"
1256
+ },
1257
+ {
1258
+ "g": "Rotation Pools",
1259
+ "icon": "πŸ”„",
1260
+ "k": "ANTHROPIC_API_KEYS",
1261
+ "lbl": "Anthropic pool (comma-sep)",
1262
+ "type": "text",
1263
+ "tag": "advanced"
1264
+ },
1265
+ {
1266
+ "g": "Rotation Pools",
1267
+ "icon": "πŸ”„",
1268
+ "k": "OPENAI_API_KEYS",
1269
+ "lbl": "OpenAI pool",
1270
+ "type": "text",
1271
+ "tag": "advanced"
1272
+ },
1273
+ {
1274
+ "g": "Rotation Pools",
1275
+ "icon": "πŸ”„",
1276
+ "k": "GEMINI_API_KEYS",
1277
+ "lbl": "Gemini pool",
1278
+ "type": "text",
1279
+ "tag": "advanced"
1280
+ },
1281
+ {
1282
+ "g": "Rotation Pools",
1283
+ "icon": "πŸ”„",
1284
+ "k": "GOOGLE_API_KEYS",
1285
+ "lbl": "Google pool",
1286
+ "type": "text",
1287
+ "tag": "advanced"
1288
+ },
1289
+ {
1290
+ "g": "Rotation Pools",
1291
+ "icon": "πŸ”„",
1292
+ "k": "DEEPSEEK_API_KEYS",
1293
+ "lbl": "DeepSeek pool",
1294
+ "type": "text",
1295
+ "tag": "advanced"
1296
+ },
1297
+ {
1298
+ "g": "Rotation Pools",
1299
+ "icon": "πŸ”„",
1300
+ "k": "OPENROUTER_API_KEYS",
1301
+ "lbl": "OpenRouter pool",
1302
+ "type": "text",
1303
+ "tag": "advanced"
1304
+ },
1305
+ {
1306
+ "g": "Rotation Pools",
1307
+ "icon": "πŸ”„",
1308
+ "k": "OPENCODE_API_KEYS",
1309
+ "lbl": "OpenCode pool",
1310
+ "type": "text",
1311
+ "tag": "advanced"
1312
+ },
1313
+ {
1314
+ "g": "Rotation Pools",
1315
+ "icon": "πŸ”„",
1316
+ "k": "KILOCODE_API_KEYS",
1317
+ "lbl": "KiloCode pool",
1318
+ "type": "text",
1319
+ "tag": "advanced"
1320
+ },
1321
+ {
1322
+ "g": "Rotation Pools",
1323
+ "icon": "πŸ”„",
1324
+ "k": "ZAI_API_KEYS",
1325
+ "lbl": "Z.ai / GLM pool",
1326
+ "type": "text",
1327
+ "tag": "advanced"
1328
+ },
1329
+ {
1330
+ "g": "Rotation Pools",
1331
+ "icon": "πŸ”„",
1332
+ "k": "MOONSHOT_API_KEYS",
1333
+ "lbl": "Moonshot / Kimi pool",
1334
+ "type": "text",
1335
+ "tag": "advanced"
1336
+ },
1337
+ {
1338
+ "g": "Rotation Pools",
1339
+ "icon": "πŸ”„",
1340
+ "k": "MINIMAX_API_KEYS",
1341
+ "lbl": "MiniMax pool",
1342
+ "type": "text",
1343
+ "tag": "advanced"
1344
+ },
1345
+ {
1346
+ "g": "Rotation Pools",
1347
+ "icon": "πŸ”„",
1348
+ "k": "XIAOMI_API_KEYS",
1349
+ "lbl": "Xiaomi pool",
1350
+ "type": "text",
1351
+ "tag": "advanced"
1352
+ },
1353
+ {
1354
+ "g": "Rotation Pools",
1355
+ "icon": "πŸ”„",
1356
+ "k": "VOLCANO_ENGINE_API_KEYS",
1357
+ "lbl": "Volcano Engine pool",
1358
+ "type": "text",
1359
+ "tag": "advanced"
1360
+ },
1361
+ {
1362
+ "g": "Rotation Pools",
1363
+ "icon": "πŸ”„",
1364
+ "k": "BYTEPLUS_API_KEYS",
1365
+ "lbl": "BytePlus pool",
1366
+ "type": "text",
1367
+ "tag": "advanced"
1368
+ },
1369
+ {
1370
+ "g": "Rotation Pools",
1371
+ "icon": "πŸ”„",
1372
+ "k": "MISTRAL_API_KEYS",
1373
+ "lbl": "Mistral pool",
1374
+ "type": "text",
1375
+ "tag": "advanced"
1376
+ },
1377
+ {
1378
+ "g": "Rotation Pools",
1379
+ "icon": "πŸ”„",
1380
+ "k": "XAI_API_KEYS",
1381
+ "lbl": "xAI pool",
1382
+ "type": "text",
1383
+ "tag": "advanced"
1384
+ },
1385
+ {
1386
+ "g": "Rotation Pools",
1387
+ "icon": "πŸ”„",
1388
+ "k": "NVIDIA_API_KEYS",
1389
+ "lbl": "NVIDIA pool",
1390
+ "type": "text",
1391
+ "tag": "advanced"
1392
+ },
1393
+ {
1394
+ "g": "Rotation Pools",
1395
+ "icon": "πŸ”„",
1396
+ "k": "GROQ_API_KEYS",
1397
+ "lbl": "Groq pool",
1398
+ "type": "text",
1399
+ "tag": "advanced"
1400
+ },
1401
+ {
1402
+ "g": "Rotation Pools",
1403
+ "icon": "πŸ”„",
1404
+ "k": "COHERE_API_KEYS",
1405
+ "lbl": "Cohere pool",
1406
+ "type": "text",
1407
+ "tag": "advanced"
1408
+ },
1409
+ {
1410
+ "g": "Rotation Pools",
1411
+ "icon": "πŸ”„",
1412
+ "k": "TOGETHER_API_KEYS",
1413
+ "lbl": "Together pool",
1414
+ "type": "text",
1415
+ "tag": "advanced"
1416
+ },
1417
+ {
1418
+ "g": "Rotation Pools",
1419
+ "icon": "πŸ”„",
1420
+ "k": "CEREBRAS_API_KEYS",
1421
+ "lbl": "Cerebras pool",
1422
+ "type": "text",
1423
+ "tag": "advanced"
1424
+ },
1425
+ {
1426
+ "g": "Rotation Pools",
1427
+ "icon": "πŸ”„",
1428
+ "k": "HUGGINGFACE_HUB_TOKENS",
1429
+ "lbl": "HF token pool",
1430
+ "type": "text"
1431
+ },
1432
+ {
1433
+ "g": "Model Lists",
1434
+ "icon": "πŸ“‹",
1435
+ "k": "OPENAI_MODELS",
1436
+ "lbl": "Visible OpenAI models",
1437
+ "type": "model_list",
1438
+ "options_key": "OPENAI_MODELS",
1439
+ "ph": "Select models to build a comma list",
1440
+ "tag": "optional"
1441
+ },
1442
+ {
1443
+ "g": "Model Lists",
1444
+ "icon": "πŸ“‹",
1445
+ "k": "ANTHROPIC_MODELS",
1446
+ "lbl": "Visible Anthropic models",
1447
+ "type": "model_list",
1448
+ "options_key": "ANTHROPIC_MODELS",
1449
+ "ph": "Select models to build a comma list",
1450
+ "tag": "optional"
1451
+ },
1452
+ {
1453
+ "g": "Model Lists",
1454
+ "icon": "πŸ“‹",
1455
+ "k": "GEMINI_MODELS",
1456
+ "lbl": "Visible Gemini models",
1457
+ "type": "model_list",
1458
+ "options_key": "GEMINI_MODELS",
1459
+ "ph": "Select models to build a comma list",
1460
+ "tag": "optional"
1461
+ },
1462
+ {
1463
+ "g": "Model Lists",
1464
+ "icon": "πŸ“‹",
1465
+ "k": "DEEPSEEK_MODELS",
1466
+ "lbl": "Visible DeepSeek models",
1467
+ "type": "model_list",
1468
+ "options_key": "DEEPSEEK_MODELS",
1469
+ "ph": "Select models to build a comma list",
1470
+ "tag": "optional"
1471
+ },
1472
+ {
1473
+ "g": "Model Lists",
1474
+ "icon": "πŸ“‹",
1475
+ "k": "OPENROUTER_MODELS",
1476
+ "lbl": "Visible OpenRouter models",
1477
+ "type": "model_list",
1478
+ "options_key": "OPENROUTER_MODELS",
1479
+ "ph": "Select models to build a comma list",
1480
+ "tag": "optional"
1481
+ },
1482
+ {
1483
+ "g": "Model Lists",
1484
+ "icon": "πŸ“‹",
1485
+ "k": "GROQ_MODELS",
1486
+ "lbl": "Visible Groq models",
1487
+ "type": "model_list",
1488
+ "options_key": "GROQ_MODELS",
1489
+ "ph": "Select models to build a comma list",
1490
+ "tag": "optional"
1491
+ },
1492
+ {
1493
+ "g": "Model Lists",
1494
+ "icon": "πŸ“‹",
1495
+ "k": "MISTRAL_MODELS",
1496
+ "lbl": "Visible Mistral models",
1497
+ "type": "model_list",
1498
+ "options_key": "MISTRAL_MODELS",
1499
+ "ph": "Select models to build a comma list",
1500
+ "tag": "optional"
1501
+ },
1502
+ {
1503
+ "g": "Model Lists",
1504
+ "icon": "πŸ“‹",
1505
+ "k": "XAI_MODELS",
1506
+ "lbl": "Visible xAI models",
1507
+ "type": "model_list",
1508
+ "options_key": "XAI_MODELS",
1509
+ "ph": "Select models to build a comma list",
1510
+ "tag": "optional"
1511
+ },
1512
+ {
1513
+ "g": "Model Lists",
1514
+ "icon": "πŸ“‹",
1515
+ "k": "COHERE_MODELS",
1516
+ "lbl": "Visible Cohere models",
1517
+ "type": "model_list",
1518
+ "options_key": "COHERE_MODELS",
1519
+ "ph": "Select models to build a comma list",
1520
+ "tag": "optional"
1521
+ },
1522
+ {
1523
+ "g": "Model Lists",
1524
+ "icon": "πŸ“‹",
1525
+ "k": "TOGETHER_MODELS",
1526
+ "lbl": "Visible Together models",
1527
+ "type": "model_list",
1528
+ "options_key": "TOGETHER_MODELS",
1529
+ "ph": "Select models to build a comma list",
1530
+ "tag": "optional"
1531
+ },
1532
+ {
1533
+ "g": "Model Lists",
1534
+ "icon": "πŸ“‹",
1535
+ "k": "CEREBRAS_MODELS",
1536
+ "lbl": "Visible Cerebras models",
1537
+ "type": "model_list",
1538
+ "options_key": "CEREBRAS_MODELS",
1539
+ "ph": "Select models to build a comma list",
1540
+ "tag": "optional"
1541
+ },
1542
+ {
1543
+ "g": "Model Lists",
1544
+ "icon": "πŸ“‹",
1545
+ "k": "NVIDIA_MODELS",
1546
+ "lbl": "Visible NVIDIA models",
1547
+ "type": "model_list",
1548
+ "options_key": "NVIDIA_MODELS",
1549
+ "ph": "Select models to build a comma list",
1550
+ "tag": "optional"
1551
+ },
1552
+ {
1553
+ "g": "Model Lists",
1554
+ "icon": "πŸ“‹",
1555
+ "k": "KILOCODE_MODELS",
1556
+ "lbl": "Visible KiloCode models",
1557
+ "type": "model_list",
1558
+ "options_key": "KILOCODE_MODELS",
1559
+ "ph": "Select models to build a comma list",
1560
+ "tag": "optional"
1561
+ },
1562
+ {
1563
+ "g": "Model Lists",
1564
+ "icon": "πŸ“‹",
1565
+ "k": "OPENCODE_MODELS",
1566
+ "lbl": "Visible OpenCode models",
1567
+ "type": "model_list",
1568
+ "options_key": "OPENCODE_MODELS",
1569
+ "ph": "Select models to build a comma list",
1570
+ "tag": "optional"
1571
+ },
1572
+ {
1573
+ "g": "Model Lists",
1574
+ "icon": "πŸ“‹",
1575
+ "k": "ZAI_MODELS",
1576
+ "lbl": "Visible Z.ai / GLM models",
1577
+ "type": "model_list",
1578
+ "options_key": "ZAI_MODELS",
1579
+ "ph": "Select models to build a comma list",
1580
+ "tag": "optional"
1581
+ },
1582
+ {
1583
+ "g": "Model Lists",
1584
+ "icon": "πŸ“‹",
1585
+ "k": "MOONSHOT_MODELS",
1586
+ "lbl": "Visible Moonshot / Kimi models",
1587
+ "type": "model_list",
1588
+ "options_key": "MOONSHOT_MODELS",
1589
+ "ph": "Select models to build a comma list",
1590
+ "tag": "optional"
1591
+ },
1592
+ {
1593
+ "g": "Model Lists",
1594
+ "icon": "πŸ“‹",
1595
+ "k": "MINIMAX_MODELS",
1596
+ "lbl": "Visible MiniMax models",
1597
+ "type": "model_list",
1598
+ "options_key": "MINIMAX_MODELS",
1599
+ "ph": "Select models to build a comma list",
1600
+ "tag": "optional"
1601
+ },
1602
+ {
1603
+ "g": "Model Lists",
1604
+ "icon": "πŸ“‹",
1605
+ "k": "XIAOMI_MODELS",
1606
+ "lbl": "Visible Xiaomi models",
1607
+ "type": "model_list",
1608
+ "options_key": "XIAOMI_MODELS",
1609
+ "ph": "Select models to build a comma list",
1610
+ "tag": "optional"
1611
+ },
1612
+ {
1613
+ "g": "Model Lists",
1614
+ "icon": "πŸ“‹",
1615
+ "k": "VOLCANO_ENGINE_MODELS",
1616
+ "lbl": "Visible Volcano Engine models",
1617
+ "type": "model_list",
1618
+ "options_key": "VOLCANO_ENGINE_MODELS",
1619
+ "ph": "Select models to build a comma list",
1620
+ "tag": "optional"
1621
+ },
1622
+ {
1623
+ "g": "Model Lists",
1624
+ "icon": "πŸ“‹",
1625
+ "k": "BYTEPLUS_MODELS",
1626
+ "lbl": "Visible BytePlus models",
1627
+ "type": "model_list",
1628
+ "options_key": "BYTEPLUS_MODELS",
1629
+ "ph": "Select models to build a comma list",
1630
+ "tag": "optional"
1631
+ },
1632
+ {
1633
+ "g": "Model Lists",
1634
+ "icon": "πŸ“‹",
1635
+ "k": "QIANFAN_MODELS",
1636
+ "lbl": "Visible Qianfan models",
1637
+ "type": "model_list",
1638
+ "options_key": "QIANFAN_MODELS",
1639
+ "ph": "Select models to build a comma list",
1640
+ "tag": "optional"
1641
+ },
1642
+ {
1643
+ "g": "Model Lists",
1644
+ "icon": "πŸ“‹",
1645
+ "k": "MODELSTUDIO_MODELS",
1646
+ "lbl": "Visible ModelStudio models",
1647
+ "type": "model_list",
1648
+ "options_key": "MODELSTUDIO_MODELS",
1649
+ "ph": "Select models to build a comma list",
1650
+ "tag": "optional"
1651
+ },
1652
+ {
1653
+ "g": "Model Lists",
1654
+ "icon": "πŸ“‹",
1655
+ "k": "KIMI_MODELS",
1656
+ "lbl": "Visible Kimi models",
1657
+ "type": "model_list",
1658
+ "options_key": "KIMI_MODELS",
1659
+ "ph": "Select models to build a comma list",
1660
+ "tag": "optional"
1661
+ },
1662
+ {
1663
+ "g": "Model Lists",
1664
+ "icon": "πŸ“‹",
1665
+ "k": "HUGGINGFACE_MODELS",
1666
+ "lbl": "Visible Hugging Face models",
1667
+ "type": "model_list",
1668
+ "options_key": "HUGGINGFACE_MODELS",
1669
+ "ph": "Select models to build a comma list",
1670
+ "tag": "optional"
1671
+ },
1672
+ {
1673
+ "g": "Model Lists",
1674
+ "icon": "πŸ“‹",
1675
+ "k": "GITHUB_COPILOT_MODELS",
1676
+ "lbl": "Visible GitHub Copilot models",
1677
+ "type": "model_list",
1678
+ "options_key": "GITHUB_COPILOT_MODELS",
1679
+ "ph": "Select models to build a comma list",
1680
+ "tag": "optional"
1681
+ },
1682
+ {
1683
+ "g": "Custom Provider",
1684
+ "icon": "πŸ”Œ",
1685
+ "k": "CUSTOM_PROVIDER_NAME",
1686
+ "lbl": "Provider display name",
1687
+ "type": "text",
1688
+ "tag": "feature"
1689
+ },
1690
+ {
1691
+ "g": "Custom Provider",
1692
+ "icon": "πŸ”Œ",
1693
+ "k": "CUSTOM_BASE_URL",
1694
+ "lbl": "OpenAI-compatible base URL",
1695
+ "type": "text",
1696
+ "tag": "feature"
1697
+ },
1698
+ {
1699
+ "g": "Custom Provider",
1700
+ "icon": "πŸ”Œ",
1701
+ "k": "CUSTOM_MODEL_ID",
1702
+ "lbl": "Model ID",
1703
+ "type": "model",
1704
+ "options_key": "LLM_MODEL",
1705
+ "ph": "custom model id",
1706
+ "tag": "feature"
1707
+ },
1708
+ {
1709
+ "g": "Custom Provider",
1710
+ "icon": "πŸ”Œ",
1711
+ "k": "CUSTOM_MODEL_NAME",
1712
+ "lbl": "Friendly model name",
1713
+ "type": "text",
1714
+ "tag": "feature"
1715
+ },
1716
+ {
1717
+ "g": "Custom Provider",
1718
+ "icon": "πŸ”Œ",
1719
+ "k": "CUSTOM_API_KEY",
1720
+ "lbl": "Provider API key",
1721
+ "type": "password",
1722
+ "tag": "credential"
1723
+ },
1724
+ {
1725
+ "g": "Custom Provider",
1726
+ "icon": "πŸ”Œ",
1727
+ "k": "CUSTOM_API_TYPE",
1728
+ "lbl": "API type",
1729
+ "type": "select",
1730
+ "options": [
1731
+ "openai-completions",
1732
+ "openai-chat-completions",
1733
+ "anthropic",
1734
+ "gemini",
1735
+ "openrouter"
1736
+ ],
1737
+ "ph": "openai-completions",
1738
+ "tag": "feature"
1739
+ },
1740
+ {
1741
+ "g": "Custom Provider",
1742
+ "icon": "πŸ”Œ",
1743
+ "k": "CUSTOM_CONTEXT_WINDOW",
1744
+ "lbl": "Context window",
1745
+ "type": "number",
1746
+ "ph": "128000",
1747
+ "tag": "advanced"
1748
+ },
1749
+ {
1750
+ "g": "Custom Provider",
1751
+ "icon": "πŸ”Œ",
1752
+ "k": "CUSTOM_MAX_TOKENS",
1753
+ "lbl": "Max output tokens",
1754
+ "type": "number",
1755
+ "ph": "500",
1756
+ "tag": "advanced"
1757
+ },
1758
+ {
1759
+ "g": "Telegram",
1760
+ "icon": "✈️",
1761
+ "k": "TELEGRAM_BOT_TOKEN",
1762
+ "lbl": "Bot token from BotFather",
1763
+ "type": "password",
1764
+ "common": 1,
1765
+ "tag": "credential"
1766
+ },
1767
+ {
1768
+ "g": "Telegram",
1769
+ "icon": "✈️",
1770
+ "k": "TELEGRAM_ALLOWED_USERS",
1771
+ "lbl": "Allowed user IDs (comma)",
1772
+ "type": "text",
1773
+ "ph": "123456789,987654321",
1774
+ "common": 1,
1775
+ "tag": "critical"
1776
+ },
1777
+ {
1778
+ "g": "Telegram",
1779
+ "icon": "✈️",
1780
+ "k": "TELEGRAM_USER_ID",
1781
+ "lbl": "Single Telegram user ID",
1782
+ "type": "text",
1783
+ "tag": "optional"
1784
+ },
1785
+ {
1786
+ "g": "Telegram",
1787
+ "icon": "✈️",
1788
+ "k": "TELEGRAM_USER_IDS",
1789
+ "lbl": "Telegram user IDs (comma)",
1790
+ "type": "text",
1791
+ "tag": "optional"
1792
+ },
1793
+ {
1794
+ "g": "Deployment",
1795
+ "icon": "🧭",
1796
+ "k": "APP_BASE",
1797
+ "lbl": "Public app base path",
1798
+ "type": "text",
1799
+ "ph": "/app",
1800
+ "tag": "advanced"
1801
+ },
1802
+ {
1803
+ "g": "Deployment",
1804
+ "icon": "🧭",
1805
+ "k": "BACKUP_DATASET",
1806
+ "lbl": "Backup dataset alias",
1807
+ "type": "text",
1808
+ "ph": "huggingclaw-backup",
1809
+ "tag": "optional"
1810
+ },
1811
+ {
1812
+ "g": "Deployment",
1813
+ "icon": "🧭",
1814
+ "k": "SPACE_AUTHOR_NAME",
1815
+ "lbl": "HF Space author name",
1816
+ "type": "text",
1817
+ "tag": "optional"
1818
+ },
1819
+ {
1820
+ "g": "Deployment",
1821
+ "icon": "🧭",
1822
+ "k": "SPACE_HOST",
1823
+ "lbl": "HF Space host domain",
1824
+ "type": "text",
1825
+ "tag": "optional"
1826
+ },
1827
+ {
1828
+ "g": "Deployment",
1829
+ "icon": "🧭",
1830
+ "k": "PORT",
1831
+ "lbl": "Public dashboard port",
1832
+ "type": "number",
1833
+ "ph": "7861",
1834
+ "tag": "advanced"
1835
+ },
1836
+ {
1837
+ "g": "Deployment",
1838
+ "icon": "🧭",
1839
+ "k": "GATEWAY_PORT",
1840
+ "lbl": "OpenClaw internal port",
1841
+ "type": "number",
1842
+ "ph": "7860",
1843
+ "tag": "advanced"
1844
+ },
1845
+ {
1846
+ "g": "Deployment",
1847
+ "icon": "🧭",
1848
+ "k": "JUPYTER_PORT",
1849
+ "lbl": "Jupyter internal port",
1850
+ "type": "number",
1851
+ "ph": "8888",
1852
+ "tag": "advanced"
1853
+ },
1854
+ {
1855
+ "g": "Deployment",
1856
+ "icon": "🧭",
1857
+ "k": "JUPYTER_BASE",
1858
+ "lbl": "Jupyter public base path",
1859
+ "type": "text",
1860
+ "ph": "/terminal",
1861
+ "tag": "advanced"
1862
+ },
1863
+ {
1864
+ "g": "Rotation Pools",
1865
+ "icon": "πŸ”„",
1866
+ "k": "AI_GATEWAY_API_KEYS",
1867
+ "lbl": "AI Gateway pool (comma-sep)",
1868
+ "type": "text",
1869
+ "tag": "advanced"
1870
+ },
1871
+ {
1872
+ "g": "Rotation Pools",
1873
+ "icon": "πŸ”„",
1874
+ "k": "COPILOT_GITHUB_TOKENS",
1875
+ "lbl": "GitHub Copilot token pool",
1876
+ "type": "text",
1877
+ "tag": "advanced"
1878
+ },
1879
+ {
1880
+ "g": "Rotation Pools",
1881
+ "icon": "πŸ”„",
1882
+ "k": "KIMI_API_KEYS",
1883
+ "lbl": "Kimi pool",
1884
+ "type": "text",
1885
+ "tag": "advanced"
1886
+ },
1887
+ {
1888
+ "g": "Rotation Pools",
1889
+ "icon": "πŸ”„",
1890
+ "k": "MODELSTUDIO_API_KEYS",
1891
+ "lbl": "ModelStudio pool",
1892
+ "type": "text",
1893
+ "tag": "advanced"
1894
+ },
1895
+ {
1896
+ "g": "Rotation Pools",
1897
+ "icon": "πŸ”„",
1898
+ "k": "QIANFAN_API_KEYS",
1899
+ "lbl": "Qianfan pool",
1900
+ "type": "text",
1901
+ "tag": "advanced"
1902
+ },
1903
+ {
1904
+ "g": "Rotation Pools",
1905
+ "icon": "πŸ”„",
1906
+ "k": "SYNTHETIC_API_KEYS",
1907
+ "lbl": "Synthetic pool",
1908
+ "type": "text",
1909
+ "tag": "advanced"
1910
+ },
1911
+ {
1912
+ "g": "Rotation Pools",
1913
+ "icon": "πŸ”„",
1914
+ "k": "VENICE_API_KEYS",
1915
+ "lbl": "Venice pool",
1916
+ "type": "text",
1917
+ "tag": "advanced"
1918
+ },
1919
+ {
1920
+ "g": "Model Lists",
1921
+ "icon": "πŸ“‹",
1922
+ "k": "VENICE_MODELS",
1923
+ "lbl": "Visible Venice models",
1924
+ "type": "model_list",
1925
+ "options_key": "VENICE_MODELS",
1926
+ "ph": "Select models to build a comma list",
1927
+ "tag": "optional"
1928
+ },
1929
+ {
1930
+ "g": "Model Lists",
1931
+ "icon": "πŸ“‹",
1932
+ "k": "SYNTHETIC_MODELS",
1933
+ "lbl": "Visible Synthetic models",
1934
+ "type": "model_list",
1935
+ "options_key": "SYNTHETIC_MODELS",
1936
+ "ph": "Select models to build a comma list",
1937
+ "tag": "optional"
1938
+ }
1939
+ ]
1940
+
1941
+ const ICONS = {
1942
+ All:'🏠', Core:'⚑', Startup:'πŸš€', DevData:'πŸ§ͺ', WhatsApp:'πŸ’¬',
1943
+ Cloudflare:'☁️', Gateway:'πŸ”€', Logging:'πŸ“', Network:'🌐', Plugins:'πŸ”Œ',
1944
+ Deployment:'🧭', 'Provider Keys':'πŸ”‘', 'Rotation Pools':'πŸ”„',
1945
+ 'Model Lists':'πŸ“‹', 'Custom Provider':'🧩', Telegram:'✈️',
1946
+ Backup:'πŸ’Ύ', Runtime:'βš™οΈ', Integrations:'πŸ”—', 'Custom Env':'πŸ”§'
1947
+ };
1948
+ const $ = id => document.getElementById(id);
1949
+ const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({
1950
+ '&': '&amp;',
1951
+ '<': '&lt;',
1952
+ '>': '&gt;',
1953
+ '"': '&quot;',
1954
+ "'": '&#39;'
1955
+ }[c]));
1956
+ const safeKey = k => /^[A-Z_][A-Z0-9_]*$/.test(k) && !['HUGGINGCLAW_ENV_BUNDLE', 'ENV_BUNDLE'].includes(k);
1957
+
1958
+ function encodeBundle(obj) {
1959
+ const j = JSON.stringify(obj);
1960
+ let b = '';
1961
+ for (const x of new TextEncoder().encode(j)) b += String.fromCharCode(x);
1962
+ return btoa(b).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
1963
+ }
1964
+
1965
+ function decodeBundle(raw) {
1966
+ try {
1967
+ raw = String(raw || '').trim();
1968
+ if (!raw) return {};
1969
+
1970
+ if (raw.includes('HUGGINGCLAW_ENV_BUNDLE=')) {
1971
+ raw = raw.split('HUGGINGCLAW_ENV_BUNDLE=').pop().trim();
1972
+ }
1973
+
1974
+ if (
1975
+ (raw.startsWith('"') && raw.endsWith('"')) ||
1976
+ (raw.startsWith("'") && raw.endsWith("'"))
1977
+ ) {
1978
+ raw = raw.slice(1, -1);
1979
+ }
1980
+
1981
+ if (raw.startsWith('{')) return JSON.parse(raw);
1982
+
1983
+ const p = raw + '='.repeat((4 - raw.length % 4) % 4);
1984
+ const b = atob(p.replace(/-/g, '+').replace(/_/g, '/'));
1985
+ const bytes = Uint8Array.from(b, c => c.charCodeAt(0));
1986
+ return JSON.parse(new TextDecoder().decode(bytes));
1987
+ } catch {
1988
+ return {};
1989
+ }
1990
+ }
1991
+
1992
+ function parseEnv(text) {
1993
+ text = String(text || '').trim();
1994
+ if (!text) return {};
1995
+
1996
+ if (
1997
+ text.startsWith('{') ||
1998
+ /^[A-Za-z0-9_-]{20,}$/.test(text) ||
1999
+ text.includes('HUGGINGCLAW_ENV_BUNDLE=')
2000
+ ) {
2001
+ return decodeBundle(text);
2002
+ }
2003
+
2004
+ const out = {};
2005
+ for (let line of text.split(/\r?\n/)) {
2006
+ line = line.trim();
2007
+ if (!line || line.startsWith('#')) continue;
2008
+ if (line.startsWith('export ')) line = line.slice(7).trim();
2009
+ const i = line.indexOf('=');
2010
+ if (i < 1) continue;
2011
+
2012
+ const key = line.slice(0, i).trim();
2013
+ let val = line.slice(i + 1).trim();
2014
+
2015
+ if (
2016
+ (val.startsWith('"') && val.endsWith('"')) ||
2017
+ (val.startsWith("'") && val.endsWith("'"))
2018
+ ) {
2019
+ val = val.slice(1, -1);
2020
+ }
2021
+
2022
+ if (safeKey(key)) out[key] = val;
2023
+ }
2024
+ return out;
2025
+ }
2026
+
2027
+ function showToast(msg = 'Copied!') {
2028
+ const t = $('toast');
2029
+ t.textContent = msg;
2030
+ t.classList.add('show');
2031
+ setTimeout(() => t.classList.remove('show'), 1500);
2032
+ }
2033
+
2034
+ let activeGroup = 'All';
2035
+ let customCount = 0;
2036
+ const GROUPS = ['All', ...[...new Set(FIELDS.map(f => f.g))], 'Custom Env'];
2037
+
2038
+ function renderSidebar() {
2039
+ const sb = $('sidebar');
2040
+ sb.innerHTML = '<div class="sb-label">Groups</div>';
2041
+ GROUPS.forEach(g => {
2042
+ const btn = document.createElement('button');
2043
+ btn.className = 'nav-btn' + (activeGroup === g ? ' active' : '');
2044
+ btn.dataset.group = g;
2045
+ const id = 'nc_' + g.replace(/\W/g, '_');
2046
+ btn.innerHTML = `<span class="nav-icon">${ICONS[g] || 'πŸ“'}</span><span class="nav-label">${esc(g)}</span><span class="nav-count" id="${id}">0</span>`;
2047
+ btn.onclick = () => {
2048
+ activeGroup = g;
2049
+ renderSidebar();
2050
+ filter();
2051
+ };
2052
+ sb.appendChild(btn);
2053
+ });
2054
+ }
2055
+
2056
+ function renderOptionsHTML(field) {
2057
+ const src = field.options || MODEL_CATALOGS[field.options_key] || [];
2058
+
2059
+ if (field.options_key === 'LLM_MODEL') {
2060
+ const groups = MODEL_CATALOGS.LLM_MODEL || {};
2061
+ return Object.entries(groups).map(([group, items]) => {
2062
+ const options = items.map(v => `<option value="${esc(v)}">${esc(v)}</option>`).join('');
2063
+ return `<optgroup label="${esc(group)}">${options}</optgroup>`;
2064
+ }).join('');
2065
+ }
2066
+
2067
+ if (Array.isArray(src)) {
2068
+ return src.map(v => `<option value="${esc(v)}">${esc(v)}</option>`).join('');
2069
+ }
2070
+
2071
+ return '';
2072
+ }
2073
+
2074
+ function defaultValueFor(field) {
2075
+ if (field.type === 'toggle') {
2076
+ const on = String(field.ph ?? '').toLowerCase();
2077
+ return ['1', 'true', 'yes', 'on', 'enabled'].includes(on) ? 'true' : 'false';
2078
+ }
2079
+ if (field.type === 'select') return String(field.ph ?? '');
2080
+ return '';
2081
+ }
2082
+
2083
+ function valueControlHTML(field) {
2084
+ const key = esc(field.k);
2085
+ const placeholder = esc(field.ph || field.lbl || '');
2086
+ const isSecret = !!field.secret;
2087
+ const isTextarea = field.type === 'textarea' || field.type === 'model_list';
2088
+ const hasPicker = !!field.options_key || Array.isArray(field.options);
2089
+ const inputType = isSecret ? 'password' : (field.type === 'number' ? 'number' : 'text');
2090
+
2091
+ let control = '';
2092
+ if (field.type === 'toggle') {
2093
+ const initial = defaultValueFor(field);
2094
+ control = `
2095
+ <div class="toggle-shell" data-toggle-row="1" data-field="${key}">
2096
+ <input type="hidden" data-key="${key}" value="${initial}">
2097
+ <button type="button" class="tog ${initial === 'true' ? 'on' : ''}" data-toggle="${key}">${initial === 'true' ? 'On' : 'Off'}</button>
2098
+ </div>`;
2099
+ } else if (isTextarea) {
2100
+ control = `<textarea data-key="${key}" placeholder="${placeholder}" spellcheck="false"></textarea>`;
2101
+ } else {
2102
+ control = `<input type="${inputType}" data-key="${key}" placeholder="${placeholder}" spellcheck="false"/>`;
2103
+ }
2104
+
2105
+ if (!hasPicker) return control;
2106
+
2107
+ const pickerMode = field.type === 'model_list' ? 'multi' : 'single';
2108
+ const pickerLabel = field.type === 'model_list' ? 'Add model…' : 'Choose preset…';
2109
+ return `
2110
+ <div class="picker-shell" data-picker-shell="${key}" data-picker-mode="${pickerMode}">
2111
+ <div class="picker-row">
2112
+ <select class="picker-select" data-pick-for="${key}" aria-label="${esc(field.lbl || field.k)} presets">
2113
+ <option value="">${esc(pickerLabel)}</option>
2114
+ ${renderOptionsHTML(field)}
2115
+ <option value="__custom__">Custom…</option>
2116
+ </select>
2117
+ <button type="button" class="mini-btn" data-custom-for="${key}">+ Custom</button>
2118
+ <button type="button" class="mini-btn" data-clear-for="${key}">Clear</button>
2119
+ </div>
2120
+ ${control}
2121
+ </div>`;
2122
+
2123
+ return control;
2124
+ }
2125
+
2126
+ function cardHTML(f) {
2127
+ const TAG_META = {
2128
+ critical: { cls: 'badge-critical', lbl: 'critical' },
2129
+ credential: { cls: 'badge-credential', lbl: 'credential' },
2130
+ feature: { cls: 'badge-feature', lbl: 'feature' },
2131
+ optional: { cls: 'badge-optional', lbl: 'optional' },
2132
+ advanced: { cls: 'badge-advanced', lbl: 'advanced' },
2133
+ build: { cls: 'badge-build', lbl: 'build-time' },
2134
+ };
2135
+ const tm = TAG_META[f.tag] || TAG_META.optional;
2136
+ const badge = `<span class="badge ${tm.cls}">${tm.lbl}</span>`;
2137
+
2138
+ return `<div class="env-card" data-row data-group="${esc(f.g)}" data-search="${esc((f.g + ' ' + f.k + ' ' + (f.lbl || '') + ' ' + (f.tag || '')).toLowerCase())}">
2139
+ <div class="card-top">
2140
+ <input type="checkbox" class="card-check" data-check="${esc(f.k)}" ${f.common ? 'data-common="1"' : ''}>
2141
+ <div class="card-info">
2142
+ <div class="card-key">${esc(f.k)}</div>
2143
+ <div class="card-lbl">${esc(f.lbl || '')}</div>
2144
+ </div>
2145
+ ${badge}
2146
+ </div>
2147
+ <div class="card-input">${valueControlHTML(f)}</div>
2148
+ </div>`;
2149
+ }
2150
+
2151
+ function addCustomRow(key = '', val = '', enabled = false) {
2152
+ const id = customCount++;
2153
+ const row = document.createElement('div');
2154
+ row.className = 'custom-row';
2155
+ row.dataset.customRow = id;
2156
+ row.dataset.enabled = enabled ? '1' : '0';
2157
+
2158
+ row.innerHTML = `
2159
+ <input data-ck="${id}" placeholder="CUSTOM_ENV_NAME" value="${esc(key)}">
2160
+ <input data-cv="${id}" placeholder="value" value="${esc(val)}">
2161
+ <button class="tog${enabled ? ' on' : ''}">${enabled ? 'On' : 'Off'}</button>
2162
+ `;
2163
+
2164
+ $('customRows').appendChild(row);
2165
+
2166
+ row.querySelectorAll('input').forEach(el => el.addEventListener('input', refresh));
2167
+ row.querySelector('button').onclick = () => {
2168
+ const on = row.dataset.enabled !== '1';
2169
+ row.dataset.enabled = on ? '1' : '0';
2170
+ row.querySelector('button').textContent = on ? 'On' : 'Off';
2171
+ row.querySelector('button').classList.toggle('on', on);
2172
+ refresh();
2173
+ };
2174
+ }
2175
+
2176
+ function getFieldValueInput(key) {
2177
+ return document.querySelector(`[data-key="${CSS.escape(key)}"]`);
2178
+ }
2179
+
2180
+ function setFieldValue(key, value) {
2181
+ const el = getFieldValueInput(key);
2182
+ if (!el) return;
2183
+ el.value = value ?? '';
2184
+ }
2185
+
2186
+ function appendCsvValue(existing, next) {
2187
+ const parts = String(existing || '').split(',').map(s => s.trim()).filter(Boolean);
2188
+ const val = String(next || '').trim();
2189
+ if (!val) return parts.join(', ');
2190
+ if (!parts.includes(val)) parts.push(val);
2191
+ return parts.join(', ');
2192
+ }
2193
+
2194
+ function collect() {
2195
+ const obj = {};
2196
+ document.querySelectorAll('[data-key]').forEach(el => {
2197
+ const key = el.dataset.key;
2198
+ if (!key || !safeKey(key)) return;
2199
+ // Only include if the card's checkbox is ticked
2200
+ const chk = document.querySelector(`[data-check="${CSS.escape(key)}"]`);
2201
+ if (!chk || !chk.checked) return;
2202
+ const val = String(el.value ?? '').trim();
2203
+ if (val) obj[key] = val;
2204
+ });
2205
+
2206
+ document.querySelectorAll('[data-custom-row]').forEach(row => {
2207
+ const id = row.dataset.customRow;
2208
+ const key = (row.querySelector(`[data-ck="${id}"]`)?.value || '').trim();
2209
+ const val = (row.querySelector(`[data-cv="${id}"]`)?.value || '').trim();
2210
+ if (row.dataset.enabled === '1' && safeKey(key) && val) obj[key] = val;
2211
+ });
2212
+
2213
+ return obj;
2214
+ }
2215
+
2216
+ function refresh() {
2217
+ const obj = collect();
2218
+ const keys = Object.keys(obj).sort();
2219
+ const bundle = keys.length ? encodeBundle(Object.fromEntries(keys.map(k => [k, obj[k]]))) : '';
2220
+
2221
+ $('bundleOut').value = bundle;
2222
+ $('envLineOut').value = bundle ? `HUGGINGCLAW_ENV_BUNDLE=${bundle}` : '';
2223
+
2224
+ const s = $('summary');
2225
+ if (keys.length) {
2226
+ 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>`;
2227
+ } else {
2228
+ s.innerHTML = 'No variables selected yet.';
2229
+ }
2230
+ updateCounts();
2231
+ }
2232
+
2233
+ function markSelected() {
2234
+ document.querySelectorAll('[data-row]').forEach(r => r.classList.toggle('selected', !!r.querySelector('[data-check]')?.checked));
2235
+ }
2236
+
2237
+ function updateCounts() {
2238
+ document.querySelectorAll('[id^="nc_"]').forEach(el => el.textContent = '0');
2239
+ const byGrp = {};
2240
+ document.querySelectorAll('[data-check]:checked').forEach(ch => {
2241
+ const g = ch.closest('[data-row]')?.dataset.group;
2242
+ if (g) byGrp[g] = (byGrp[g] || 0) + 1;
2243
+ });
2244
+ const custOn = document.querySelectorAll('[data-custom-row][data-enabled="1"]').length;
2245
+ const total = Object.values(byGrp).reduce((a, b) => a + b, 0) + custOn;
2246
+ const allEl = document.getElementById('nc_All'); if (allEl) allEl.textContent = total;
2247
+ Object.entries(byGrp).forEach(([g, c]) => {
2248
+ const el = document.getElementById('nc_' + g.replace(/\W/g, '_'));
2249
+ if (el) el.textContent = c;
2250
+ });
2251
+ const custEl = document.getElementById('nc_Custom_Env'); if (custEl) custEl.textContent = custOn;
2252
+ }
2253
+
2254
+ function filter() {
2255
+ const q = $('search').value.trim().toLowerCase();
2256
+ document.querySelectorAll('.sec[data-section]').forEach(sec => {
2257
+ const grp = sec.dataset.section;
2258
+ const gMatch = activeGroup === 'All' || activeGroup === grp;
2259
+ if (!gMatch) { sec.classList.add('sec-hidden'); return; }
2260
+ let any = false;
2261
+ sec.querySelectorAll('[data-row]').forEach(card => {
2262
+ const m = !q || card.dataset.search.includes(q);
2263
+ card.classList.toggle('hidden', !m);
2264
+ if (m) any = true;
2265
+ });
2266
+ sec.classList.toggle('sec-hidden', !any);
2267
+ });
2268
+ const cs = $('customSec');
2269
+ if (cs) cs.style.display = (activeGroup === 'All' || activeGroup === 'Custom Env') ? '' : 'none';
2270
+ document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.group === activeGroup));
2271
+ }
2272
+
2273
+ function clearForm() {
2274
+ document.querySelectorAll('[data-check]').forEach(c => c.checked = false);
2275
+ document.querySelectorAll('[data-key]').forEach(el => {
2276
+ if (el.closest('[data-toggle-row]')) {
2277
+ el.value = 'false';
2278
+ const btn = el.closest('.toggle-shell')?.querySelector('[data-toggle]');
2279
+ if (btn) {
2280
+ btn.textContent = 'Off';
2281
+ btn.classList.remove('on');
2282
+ }
2283
+ return;
2284
+ }
2285
+ el.value = '';
2286
+ });
2287
+ $('customRows').innerHTML = '';
2288
+ customCount = 0;
2289
+ addCustomRow();
2290
+ }
2291
+
2292
+ function applyObj(obj, replace = false) {
2293
+ if (replace) clearForm();
2294
+ for (const [key, val] of Object.entries(obj || {})) {
2295
+ if (!safeKey(key)) continue;
2296
+ const inp = getFieldValueInput(key);
2297
+ const chk = document.querySelector(`[data-check="${CSS.escape(key)}"]`);
2298
+ if (inp && chk) {
2299
+ inp.value = val;
2300
+ chk.checked = true;
2301
+ const btn = inp.closest('[data-toggle-row]')?.querySelector('[data-toggle]');
2302
+ if (btn) {
2303
+ const on = String(val).trim().toLowerCase() === 'true';
2304
+ btn.textContent = on ? 'On' : 'Off';
2305
+ btn.classList.toggle('on', on);
2306
+ inp.value = on ? 'true' : 'false';
2307
+ }
2308
+ } else {
2309
+ addCustomRow(key, val, true);
2310
+ }
2311
+ }
2312
+ markSelected(); filter(); refresh();
2313
+ }
2314
+
2315
+ function autoCheck(key) {
2316
+ const chk = document.querySelector(`[data-check="${CSS.escape(key)}"]`);
2317
+ if (chk && !chk.checked) {
2318
+ chk.checked = true;
2319
+ markSelected();
2320
+ }
2321
+ }
2322
+
2323
+ function handlePickerChange(sel) {
2324
+ const key = sel.dataset.pickFor;
2325
+ const mode = sel.closest('[data-picker-shell]')?.dataset.pickerMode || 'single';
2326
+ const value = sel.value;
2327
+ if (!key || !value) return;
2328
+ if (value === '__custom__') {
2329
+ sel.value = '';
2330
+ return;
2331
+ }
2332
+ const inp = getFieldValueInput(key);
2333
+ if (!inp) return;
2334
+
2335
+ if (mode === 'multi') {
2336
+ inp.value = appendCsvValue(inp.value, value);
2337
+ } else {
2338
+ inp.value = value;
2339
+ }
2340
+ sel.value = '';
2341
+ autoCheck(key);
2342
+ refresh();
2343
+ }
2344
+
2345
+ function promptCustomModel(btn) {
2346
+ const key = btn.dataset.customFor;
2347
+ const mode = btn.closest('[data-picker-shell]')?.dataset.pickerMode || 'single';
2348
+ const inp = getFieldValueInput(key);
2349
+ if (!inp) return;
2350
+ const message = mode === 'multi'
2351
+ ? 'Enter one or more custom model IDs separated by commas'
2352
+ : 'Enter a custom model ID';
2353
+ const initial = '';
2354
+ const text = prompt(message, initial);
2355
+ if (text === null) return;
2356
+ const val = String(text).trim();
2357
+ if (!val) return;
2358
+ if (mode === 'multi') {
2359
+ const vals = val.split(',').map(s => s.trim()).filter(Boolean);
2360
+ let out = inp.value || '';
2361
+ for (const v of vals) out = appendCsvValue(out, v);
2362
+ inp.value = out;
2363
+ } else {
2364
+ inp.value = val;
2365
+ }
2366
+ autoCheck(key);
2367
+ refresh();
2368
+ }
2369
+
2370
+ function resetPickerField(btn) {
2371
+ const key = btn.dataset.clearFor;
2372
+ const inp = getFieldValueInput(key);
2373
+ if (!inp) return;
2374
+ if (inp.closest('[data-toggle-row]')) {
2375
+ inp.value = 'false';
2376
+ const toggleBtn = inp.closest('.toggle-shell')?.querySelector('[data-toggle]');
2377
+ if (toggleBtn) {
2378
+ toggleBtn.textContent = 'Off';
2379
+ toggleBtn.classList.remove('on');
2380
+ }
2381
+ } else {
2382
+ inp.value = '';
2383
+ }
2384
+ refresh();
2385
+ }
2386
+
2387
+ function toggleField(key) {
2388
+ const inp = getFieldValueInput(key);
2389
+ if (!inp) return;
2390
+ const on = String(inp.value || '').trim().toLowerCase() !== 'true';
2391
+ inp.value = on ? 'true' : 'false';
2392
+ const btn = inp.closest('.toggle-shell')?.querySelector('[data-toggle]');
2393
+ if (btn) {
2394
+ btn.textContent = on ? 'On' : 'Off';
2395
+ btn.classList.toggle('on', on);
2396
+ }
2397
+ // Auto-check when turned on; uncheck when turned off
2398
+ const chk = document.querySelector(`[data-check="${CSS.escape(key)}"]`);
2399
+ if (chk) {
2400
+ chk.checked = on;
2401
+ markSelected();
2402
+ }
2403
+ refresh();
2404
+ }
2405
+
2406
+ function bindFieldEvents() {
2407
+ document.querySelectorAll('[data-check]').forEach(el => el.addEventListener('change', () => { markSelected(); refresh(); }));
2408
+ document.querySelectorAll('[data-key]').forEach(el => el.addEventListener('input', refresh));
2409
+ document.querySelectorAll('[data-toggle]').forEach(btn => btn.addEventListener('click', () => toggleField(btn.dataset.toggle)));
2410
+ document.querySelectorAll('[data-pick-for]').forEach(sel => sel.addEventListener('change', () => handlePickerChange(sel)));
2411
+ document.querySelectorAll('[data-custom-for]').forEach(btn => btn.addEventListener('click', () => promptCustomModel(btn)));
2412
+ document.querySelectorAll('[data-clear-for]').forEach(btn => btn.addEventListener('click', () => resetPickerField(btn)));
2413
+ }
2414
+
2415
+ function renderSections() {
2416
+ const grouped = {};
2417
+ FIELDS.forEach(f => { (grouped[f.g] ||= []).push(f); });
2418
+
2419
+ const wrap = $('sections');
2420
+ wrap.innerHTML = '';
2421
+ Object.entries(grouped).forEach(([grp, items]) => {
2422
+ const sec = document.createElement('div');
2423
+ sec.className = 'sec';
2424
+ sec.dataset.section = grp;
2425
+ sec.innerHTML = `
2426
+ <div class="sec-header">
2427
+ <span class="sec-icon">${ICONS[grp] || 'πŸ“'}</span>
2428
+ <span class="sec-title">${esc(grp)}</span>
2429
+ <span class="sec-count">${items.length}</span>
2430
+ <div class="sec-line"></div>
2431
+ </div>
2432
+ <div class="cards">${items.map(cardHTML).join('')}</div>`;
2433
+ wrap.appendChild(sec);
2434
+ });
2435
+ bindFieldEvents();
2436
+ }
2437
+
2438
+ function copyText(text) {
2439
+ return navigator.clipboard.writeText(text).then(
2440
+ () => showToast('Copied βœ“'),
2441
+ () => {
2442
+ const ta = document.createElement('textarea');
2443
+ ta.value = text;
2444
+ ta.style.position = 'fixed';
2445
+ ta.style.left = '-9999px';
2446
+ document.body.appendChild(ta);
2447
+ ta.select();
2448
+ document.execCommand('copy');
2449
+ ta.remove();
2450
+ showToast('Copied βœ“');
2451
+ }
2452
+ );
2453
+ }
2454
+
2455
+ // ── Init ──
2456
+ renderSidebar();
2457
+ renderSections();
2458
+ addCustomRow();
2459
+ filter();
2460
+ refresh();
2461
+
2462
+ // ── Events ──
2463
+ $('search').oninput = filter;
2464
+ $('selectCommon').onclick = () => {
2465
+ document.querySelectorAll('[data-common="1"]').forEach(c => c.checked = true);
2466
+ markSelected();
2467
+ refresh();
2468
+ };
2469
+ $('selectVisible').onclick = () => {
2470
+ document.querySelectorAll('.sec:not(.sec-hidden) [data-row]:not(.hidden) [data-check]').forEach(c => c.checked = true);
2471
+ markSelected();
2472
+ refresh();
2473
+ };
2474
+ $('clearAll').onclick = () => {
2475
+ clearForm();
2476
+ markSelected();
2477
+ filter();
2478
+ refresh();
2479
+ };
2480
+ $('applyImport').onclick = () => {
2481
+ try {
2482
+ applyObj(parseEnv($('importText').value), true);
2483
+ showToast('Imported βœ“');
2484
+ } catch (e) {
2485
+ showToast('Import failed');
2486
+ alert(e.message);
2487
+ }
2488
+ };
2489
+
2490
+ // Auto-import: paste karo aur turant parse + apply ho jaata hai
2491
+ $('importText').addEventListener('paste', () => {
2492
+ setTimeout(() => {
2493
+ try {
2494
+ const val = $('importText').value.trim();
2495
+ if (!val) return;
2496
+ applyObj(parseEnv(val), true);
2497
+ showToast('Auto-imported βœ“');
2498
+ } catch (e) {
2499
+ showToast('Import failed');
2500
+ }
2501
+ }, 0);
2502
+ });
2503
+
2504
+ // Live typing: jaise jaise type karo env format mein, bundle banta jaata hai
2505
+ $('importText').addEventListener('input', () => {
2506
+ const val = $('importText').value.trim();
2507
+ if (!val) return;
2508
+ // Sirf agar valid env/bundle format lag raha ho tabhi auto-apply
2509
+ const looksLikeEnv = val.includes('=') || val.startsWith('{') || /^[A-Za-z0-9_\-]{20,}$/.test(val);
2510
+ if (looksLikeEnv) {
2511
+ try {
2512
+ applyObj(parseEnv(val), true);
2513
+ } catch (e) { /* silent β€” user abhi type kar raha hai */ }
2514
+ }
2515
+ });
2516
+ $('addCustom').onclick = () => addCustomRow();
2517
+ $('applyBundle').onclick = () => {
2518
+ try {
2519
+ applyObj(decodeBundle($('bundleOut').value), true);
2520
+ showToast('Bundle applied βœ“');
2521
+ } catch (e) {
2522
+ showToast('Invalid bundle');
2523
+ }
2524
+ };
2525
+ $('copyBundle').onclick = () => copyText($('bundleOut').value);
2526
+ $('copyEnvLine').onclick = () => copyText($('envLineOut').value);
2527
+ $('copyJson').onclick = () => copyText(JSON.stringify(collect(), null, 2));
health-server.js CHANGED
@@ -1,58 +1,207 @@
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 +209,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 +253,467 @@ 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
+ // ── Privacy Detection ──
58
+ // Priority order:
59
+ // 1. SPACE_PRIVACY env var ("public" / "private") β€” explicit user override, most reliable
60
+ // 2. HF API call to huggingface.co β€” auto-detect
61
+ // 3. Fail-secure default: treat as private if SPACE_ID is set
62
+
63
+ // 1. Check explicit env var override first
64
+ const _spacPrivacyEnv = (process.env.SPACE_PRIVACY || "").trim().toLowerCase();
65
+ let SPACE_IS_PRIVATE;
66
+ let _privacyDetectionDone = false;
67
+ let _privacyDetectionResolve;
68
+ const privacyDetectionReady = new Promise((res) => { _privacyDetectionResolve = res; });
69
+
70
+ if (_spacPrivacyEnv === "public") {
71
+ // User explicitly set SPACE_PRIVACY=public β€” skip API call entirely
72
+ SPACE_IS_PRIVATE = false;
73
+ _privacyDetectionDone = true;
74
+ console.log("[health-server] Space privacy: public (SPACE_PRIVACY env var override)");
75
+ privacyDetectionReady.then ? void 0 : null;
76
+ _privacyDetectionResolve && _privacyDetectionResolve();
77
+ } else if (_spacPrivacyEnv === "private") {
78
+ // User explicitly set SPACE_PRIVACY=private β€” skip API call entirely
79
+ SPACE_IS_PRIVATE = true;
80
+ _privacyDetectionDone = true;
81
+ console.log("[health-server] Space privacy: private (SPACE_PRIVACY env var override)");
82
+ _privacyDetectionResolve && _privacyDetectionResolve();
83
+ } else {
84
+ // 2. Auto-detect via HF API (with fail-secure default)
85
+ // Default to private if SPACE_ID is set β€” gets corrected by API call below.
86
+ SPACE_IS_PRIVATE = !!SPACE_ID;
87
+ }
88
+
89
+ async function detectSpacePrivacy() {
90
+ // Skip if already resolved via env var
91
+ if (_spacPrivacyEnv === "public" || _spacPrivacyEnv === "private") return;
92
+ // Skip if not running on HF Spaces
93
+ if (!SPACE_ID) {
94
+ SPACE_IS_PRIVATE = false;
95
+ _privacyDetectionDone = true;
96
+ _privacyDetectionResolve();
97
+ return;
98
+ }
99
+
100
+ const token = (process.env.HF_TOKEN || "").trim();
101
+ const reqOptions = {
102
+ hostname: "huggingface.co",
103
+ path: `/api/spaces/${SPACE_ID}`,
104
+ method: "GET",
105
+ headers: Object.assign(
106
+ { "User-Agent": "HuggingClaw/health-server" },
107
+ token ? { Authorization: `Bearer ${token}` } : {}
108
+ ),
109
+ };
110
+
111
+ // Retry up to 5 times with increasing delay β€” covers transient failures at boot
112
+ const MAX_ATTEMPTS = 5;
113
+ let detected = false;
114
+
115
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
116
+ try {
117
+ const result = await new Promise((resolve) => {
118
+ const r = https.request(reqOptions, (apiRes) => {
119
+ let body = "";
120
+ apiRes.on("data", (chunk) => { body += chunk; });
121
+ apiRes.on("end", () => {
122
+ try {
123
+ if (apiRes.statusCode === 200) {
124
+ const data = JSON.parse(body);
125
+ // API confirmed privacy status
126
+ SPACE_IS_PRIVATE = data.private === true;
127
+ resolve({ ok: true, status: apiRes.statusCode });
128
+ } else if (apiRes.statusCode === 401 || apiRes.statusCode === 403) {
129
+ // 401/403 on /api/spaces means the space IS private and our token
130
+ // is missing or wrong. Mark as private.
131
+ SPACE_IS_PRIVATE = true;
132
+ resolve({ ok: true, status: apiRes.statusCode, forcedPrivate: true });
133
+ } else if (apiRes.statusCode === 404) {
134
+ // Space not found β€” shouldn't happen but treat as non-blocking; default stays.
135
+ resolve({ ok: false, status: apiRes.statusCode });
136
+ } else {
137
+ // Other non-200 β€” transient; retry
138
+ resolve({ ok: false, status: apiRes.statusCode });
139
+ }
140
+ } catch { resolve({ ok: false, status: apiRes.statusCode }); }
141
+ });
142
+ });
143
+ r.on("error", (err) => resolve({ ok: false, error: err.message }));
144
+ r.setTimeout(8000, () => { r.destroy(); resolve({ ok: false, error: "timeout" }); });
145
+ r.end();
146
+ });
147
+
148
+ console.log(`[health-server] Privacy detection attempt ${attempt}/${MAX_ATTEMPTS}: status=${result.status || "network-error"} ok=${result.ok}`);
149
+
150
+ if (result.ok) { detected = true; break; }
151
+ } catch (err) {
152
+ console.warn(`[health-server] Privacy detection attempt ${attempt} threw: ${err.message}`);
153
+ }
154
+
155
+ const delay = Math.min(2000 * attempt, 10000); // 2s, 4s, 6s, 8s, 10s
156
+ if (attempt < MAX_ATTEMPTS) {
157
+ await new Promise((r) => setTimeout(r, delay));
158
+ }
159
+ }
160
+
161
+ if (!detected) {
162
+ console.warn(
163
+ `[health-server] Privacy detection failed after ${MAX_ATTEMPTS} attempts β€” ` +
164
+ `defaulting to ${SPACE_IS_PRIVATE ? "private" : "public"}. ` +
165
+ `TIP: Set SPACE_PRIVACY=public (or private) in your Space secrets to skip API detection.`
166
+ );
167
+ } else {
168
+ console.log(`[health-server] Space privacy detected via HF API: ${SPACE_IS_PRIVATE ? "private" : "public"}`);
169
+ }
170
+
171
+ _privacyDetectionDone = true;
172
+ _privacyDetectionResolve();
173
+ }
174
+
175
+ // Only run API detection if env var override not used
176
+ if (_spacPrivacyEnv !== "public" && _spacPrivacyEnv !== "private") {
177
+ detectSpacePrivacy();
178
+ // Re-check every 5 minutes so runtime public↔private changes are picked up
179
+ setInterval(detectSpacePrivacy, 5 * 60 * 1000);
180
+ }
181
  const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
182
  "/tmp/huggingclaw-cloudflare-keepalive-status.json";
183
 
184
  function parseRequestUrl(url) {
185
+ try { return new URL(url, "http://localhost"); }
186
+ catch { return new URL("http://localhost/"); }
 
 
 
187
  }
188
 
189
  function getSyncStatus() {
190
  try {
191
+ if (fs.existsSync(SYNC_STATUS_FILE))
192
  return JSON.parse(fs.readFileSync(SYNC_STATUS_FILE, "utf8"));
 
193
  } catch {}
194
+ if (HF_BACKUP_ENABLED)
195
+ return { status: "configured", message: `Backup enabled. Waiting for sync window (${SYNC_INTERVAL}s).` };
 
 
 
 
196
  return { status: "unknown", message: "No sync data yet" };
197
  }
198
 
199
  function readGuardianStatus() {
200
+ if (!WHATSAPP_ENABLED) return { configured: false, connected: false, pairing: false };
 
 
201
  try {
202
  if (fs.existsSync(WHATSAPP_STATUS_FILE)) {
203
+ const p = JSON.parse(fs.readFileSync(WHATSAPP_STATUS_FILE, "utf8"));
204
+ return { configured: p.configured !== false, connected: p.connected === true, pairing: p.pairing === true };
 
 
 
 
205
  }
206
  } catch {}
207
  return { configured: true, connected: false, pairing: false };
 
209
 
210
  function getKeepaliveStatus() {
211
  try {
212
+ if (fs.existsSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE))
213
+ return JSON.parse(fs.readFileSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE, "utf8"));
 
 
 
214
  } catch {}
215
  return null;
216
  }
217
 
218
+ function probePort(host, port, path, timeoutMs = 1500) {
219
  return new Promise((resolve) => {
220
+ const req = http.get({ hostname: host, port, path, timeout: timeoutMs }, (res) => {
221
+ res.resume();
222
+ resolve(res.statusCode >= 200 && res.statusCode < 400);
 
 
 
 
 
 
 
 
 
 
 
 
223
  });
224
+ req.on("timeout", () => { req.destroy(); resolve(false); });
225
+ req.on("error", () => resolve(false));
226
  });
227
  }
228
 
229
  function formatUptime(ms) {
230
+ const t = Math.floor(ms / 1000);
231
+ const d = Math.floor(t / 86400), h = Math.floor((t % 86400) / 3600), m = Math.floor((t % 3600) / 60);
232
+ if (d) return `${d}d ${h}h ${m}m`;
233
+ if (h) return `${h}h ${m}m`;
234
+ return `${m}m`;
 
 
235
  }
236
 
237
+ function escapeHtml(v) {
238
+ return String(v).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
 
 
 
 
239
  }
240
 
241
+ function badge(label, tone = "neutral") {
242
  return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
243
  }
244
 
245
+ function tile({ title, value, detail = "", tone = "neutral", meta = "" }) {
 
 
 
 
 
 
246
  return `<article class="tile ${tone}">
247
+ <div class="tile-head"><span class="tile-title">${escapeHtml(title)}</span><span class="tile-dot"></span></div>
 
 
 
248
  <div class="tile-value">${value}</div>
249
  ${detail ? `<div class="tile-detail">${detail}</div>` : ""}
250
  ${meta ? `<div class="tile-meta">${meta}</div>` : ""}
 
253
 
254
  function renderDashboard(data) {
255
  const syncStatus = String(data.sync?.status || "unknown");
256
+ const syncTone = ["success","restored","synced","configured"].includes(syncStatus) ? "ok" : syncStatus === "disabled" ? "warn" : "neutral";
257
+ const kaConf = data.keepalive?.configured === true;
258
+ const kaStatus = String(data.keepalive?.status || (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"));
259
+ const kaTone = kaConf ? "ok" : process.env.CLOUDFLARE_WORKERS_TOKEN ? "warn" : "neutral";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
  const tiles = [
262
+ 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" }),
263
+ tile({ title: "Model", value: `<code>${escapeHtml(LLM_MODEL)}</code>`, detail: "Primary LLM configured", tone: "neutral" }),
264
+ tile({ title: "Runtime", value: escapeHtml(data.uptimeHuman), detail: `Public port ${PORT}`, tone: "neutral" }),
265
+ 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" }),
266
+ ];
267
+
268
+
269
+ tiles.push(
270
+ 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>` : "" }),
271
+ 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 }),
272
+ );
273
+
274
+ if (JUPYTER_ENABLED) {
275
+ 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" }));
276
+ tiles.push(tile({
277
+ title: "DevData",
278
+ value: badge(DEVDATA_ENABLED ? "Enabled" : "Disabled", DEVDATA_ENABLED ? "ok" : "neutral"),
279
+ 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",
280
+ tone: DEVDATA_ENABLED ? "ok" : "neutral",
281
+ meta: `Sync interval ${escapeHtml(DEVDATA_SYNC_INTERVAL)}s`,
282
+ }));
283
+ }
284
+
285
+ const tilesHtml = tiles.join("");
286
+
287
+ return `<!doctype html><html lang="en"><head>
288
+ <meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  <title>HuggingClaw</title>
290
  <style>
291
+ :root{color-scheme:dark;--bg:#08080f;--panel:#12111b;--line:#26243a;--text:#f6f4ff;--muted:#7f7a9e;--soft:#b8b3d7;--good:#22c55e;--warn:#f5c542;--bad:#fb7185}
292
+ *{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}
293
+ main{width:min(720px,calc(100% - 32px));margin:0 auto;padding:36px 0 44px}
294
+ header{text-align:center;margin-bottom:22px}h1{margin:0;font-size:1.65rem;line-height:1}
295
+ .subtitle{margin-top:12px;color:var(--muted);font-size:.72rem;text-transform:uppercase;letter-spacing:.14em;font-weight:800}
296
+ .btn-row{display:flex;gap:12px;margin:24px 0 20px}
297
+ .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}
298
+ .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}
299
+ .overview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:10px}
300
+ .tile{border:1px solid var(--line);background:var(--panel);border-radius:11px;padding:18px;min-height:124px;display:flex;flex-direction:column;gap:10px}
301
+ .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)}
302
+ .tile-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
303
+ .tile-title{color:var(--muted);font-size:.67rem;letter-spacing:.18em;text-transform:uppercase;font-weight:850}
304
+ .tile-dot{width:7px;height:7px;border-radius:50%;background:var(--line)}
305
+ .tile.ok .tile-dot{background:var(--good)}.tile.warn .tile-dot{background:var(--warn)}.tile.off .tile-dot{background:var(--bad)}
306
+ .tile-value{font-size:1.12rem;font-weight:850;overflow-wrap:anywhere}.tile-detail{color:var(--soft);line-height:1.45;font-size:.83rem}
307
+ .tile-meta{color:var(--muted);line-height:1.4;font-size:.75rem;margin-top:auto;overflow-wrap:anywhere}
308
+ code{background:#232234;border:1px solid #34324c;border-radius:6px;padding:2px 6px;color:var(--text);font-size:.9em}
309
+ .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}
310
+ .badge.ok{color:var(--good);border-color:rgba(34,197,94,.34);background:rgba(34,197,94,.11)}
311
+ .badge.warn{color:var(--warn);border-color:rgba(245,197,66,.34);background:rgba(245,197,66,.11)}
312
+ .badge.off{color:var(--bad);border-color:rgba(251,113,133,.34);background:rgba(251,113,133,.11)}
313
+ .badge.neutral{color:var(--soft)}
314
+ footer{color:var(--muted);text-align:center;font-size:.74rem;margin-top:18px}
315
+ @media(max-width:700px){.overview{grid-template-columns:1fr}main{width:min(100% - 22px,720px);padding-top:28px}.btn-row{flex-direction:column}}
316
+ </style></head><body><main>
317
+ <header><h1>🦞 HuggingClaw</h1><div class="subtitle">OpenClaw Gateway</div></header>
318
+ <div class="btn-row">
319
+ <a class="hero-action" data-space-link="app" href="${APP_BASE}/">Open Control UI β†’</a>
320
+ ${JUPYTER_ENABLED ? `<a class="hero-action terminal" data-space-link="terminal" href="${JUPYTER_BASE}/">πŸ’» Open Terminal β†’</a>` : ""}
321
+ <a class="hero-action env" data-space-link="env-builder" href="/env-builder">βš™οΈ Env Builder β†’</a>
322
+ </div>
323
+ <section class="overview">${tilesHtml}</section>
324
+ <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>Env Builder &amp; JupyterLab integration by<br><a href="https://github.com/anurag008w" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:none">@anurag</a></footer>
 
 
 
 
 
 
 
 
 
 
 
 
325
  </main>
326
  <script>
327
+ document.querySelectorAll('.local-time').forEach(el=>{const d=new Date(el.getAttribute('data-iso'));if(!isNaN(d))el.textContent='At '+d.toLocaleTimeString()});
328
+ const inEmbeddedApp = (() => { try { return window.top !== window.self; } catch { return true; } })();
329
+ const isDirectHfSpaceHost = /\.hf\.space$/i.test(window.location.hostname);
330
+ const HF_SPACE_URL = ${JSON.stringify(HF_SPACE_URL)};
331
+ // Server-side detected value (may be stale if page was cached β€” see /api/is-private)
332
+ let SPACE_IS_PRIVATE = ${JSON.stringify(SPACE_IS_PRIVATE)};
333
+
334
+ function applyLinkTargets() {
335
+ // Keep hero buttons in-frame for private spaces; open new tab for public spaces
336
+ // accessed via the HF iframe or directly at .hf.space.
337
+ const openInNewTab = !SPACE_IS_PRIVATE && (inEmbeddedApp || isDirectHfSpaceHost);
338
+ document.querySelectorAll('a[data-space-link]').forEach((a) => {
339
+ if (openInNewTab) {
340
+ a.setAttribute('target', '_blank');
341
+ a.setAttribute('rel', 'noopener noreferrer');
342
+ } else {
343
+ a.removeAttribute('target');
344
+ a.removeAttribute('rel');
345
  }
346
  });
347
+ }
348
+
349
+ applyLinkTargets();
350
+
351
+ // Always re-fetch the live privacy status from the server to handle:
352
+ // 1. Startup race condition where server rendered before API detection finished
353
+ // 2. Any mismatch between client-rendered value and actual server-side state
354
+ // 3. Public spaces where the fail-secure default (private) needs correcting
355
+ // Also retries after 4s in case the first fetch raced with a server-side retry.
356
+ function syncPrivacy() {
357
+ return fetch('/api/is-private', { cache: 'no-store' })
358
+ .then(r => r.json())
359
+ .then(d => {
360
+ if (d.isPrivate !== SPACE_IS_PRIVATE) {
361
+ SPACE_IS_PRIVATE = d.isPrivate;
362
+ applyLinkTargets(); // re-run: adds or removes target="_blank" on buttons
363
+ }
364
+ return d.isPrivate;
365
+ })
366
+ .catch(() => SPACE_IS_PRIVATE);
367
+ }
368
+
369
+ if (isDirectHfSpaceHost) {
370
+ // Immediate check on page load
371
+ syncPrivacy().then(isPrivate => {
372
+ // If space appears private after first check, re-verify after server retries
373
+ // complete (server retries up to 3Γ—5s = ~15s). This catches the edge case
374
+ // where a PUBLIC space returned private due to a transient API failure.
375
+ if (isPrivate) {
376
+ setTimeout(syncPrivacy, 8000);
377
+ setTimeout(syncPrivacy, 16000);
378
+ }
379
+ });
380
+ }
381
+ // Direct .hf.space access outside the HF App iframe has no valid session cookie
382
+ // for private spaces β€” HF CDN returns 404 before the request reaches the container.
383
+ // Redirect users to huggingface.co/spaces/... which authenticates them properly.
384
+ if (SPACE_IS_PRIVATE && isDirectHfSpaceHost && !inEmbeddedApp && HF_SPACE_URL) {
385
+ const notice = document.createElement('div');
386
+ 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';
387
+ 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>';
388
+ document.body.appendChild(notice);
389
+ setTimeout(() => { window.location.replace(HF_SPACE_URL); }, 300);
390
+ }
391
+ </script>
392
+ </body></html>`;
393
+ }
394
+
395
+ function renderPrivateRedirect(targetUrl) {
396
+ const safeUrl = escapeHtml(targetUrl);
397
+ return `<!doctype html><html lang="en"><head>
398
+ <meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
399
+ <title>HuggingClaw β€” Private Space</title>
400
+ <style>
401
+ :root{color-scheme:dark}
402
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
403
+ font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;
404
+ background:#08080f;color:#f6f4ff;text-align:center;padding:24px}
405
+ .card{border:1px solid #26243a;background:#12111b;border-radius:14px;padding:36px 32px;max-width:440px}
406
+ h1{margin:0 0 12px;font-size:1.5rem}
407
+ p{color:#b8b3d7;line-height:1.6;margin:0 0 24px}
408
+ .btn{display:inline-flex;align-items:center;justify-content:center;
409
+ background:#fff;color:#000;font-weight:850;font-size:.95rem;
410
+ border-radius:8px;padding:12px 28px;text-decoration:none;transition:opacity .15s}
411
+ .btn:hover{opacity:.85}
412
+ .sub{color:#7f7a9e;font-size:.78rem;margin-top:16px}
413
+ </style></head><body>
414
+ <div class="card">
415
+ <h1>πŸ”’ Private Space</h1>
416
+ <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>
417
+ <a class="btn" href="${safeUrl}">Open on Hugging Face β†’</a>
418
+ <div class="sub">Redirecting in 3 seconds&hellip;</div>
419
+ </div>
420
+ <script>
421
+ // Only auto-redirect when NOT inside an iframe (e.g. HF App tab embeds this
422
+ // page in an iframe; navigating that iframe to huggingface.co is blocked by
423
+ // X-Frame-Options and causes "refused to connect" in the browser).
424
+ const _inFrame = (() => { try { return window.top !== window.self; } catch { return true; } })();
425
+ if (!_inFrame) {
426
+ setTimeout(() => { window.location.replace(${JSON.stringify(targetUrl)}); }, 100);
427
+ }
428
  </script>
429
+ </body></html>`;
 
430
  }
431
 
432
+ function renderEnvBuilder() {
433
+ try {
434
+ return fs.readFileSync(require("path").join(__dirname, "env-builder.html"), "utf8");
435
+ } catch (exc) {
436
+ return `<!doctype html><title>Env Builder unavailable</title><pre>${escapeHtml(exc.message)}</pre>`;
437
+ }
438
+ }
439
 
440
+ // ── Generic proxy ──
441
+ function proxiedPath(url, { stripPrefix = "" } = {}) {
442
+ if (!stripPrefix) return url.pathname + url.search;
443
+ if (url.pathname === stripPrefix) return "/" + url.search;
444
+ if (url.pathname.startsWith(stripPrefix + "/")) {
445
+ return url.pathname.slice(stripPrefix.length) + url.search;
 
 
 
 
 
 
 
 
 
446
  }
447
+ return url.pathname + url.search;
448
+ }
449
 
450
+ function rewriteProxyHeaders(headers, { publicPrefix = "", targetHost = "", targetPort = "" } = {}) {
451
+ const next = { ...headers };
452
+
453
+ // Keep browser redirects inside the public HF Space path. Backends may emit
454
+ // root-relative redirects ("/login") or absolute redirects pointing at their
455
+ // internal listener ("http://127.0.0.1:8888/..."). Both break from a browser
456
+ // if we do not normalize them back to the public mount path.
457
+ if (publicPrefix && typeof next.location === "string") {
458
+ try {
459
+ const internalOrigins = new Set([
460
+ "http://huggingclaw.local",
461
+ `http://${targetHost}:${targetPort}`,
462
+ `http://localhost:${targetPort}`,
463
+ `http://127.0.0.1:${targetPort}`,
464
+ ]);
465
+ const location = new URL(next.location, "http://huggingclaw.local");
466
+ if (internalOrigins.has(location.origin)) {
467
+ let path = location.pathname;
468
+ if (path !== publicPrefix && !path.startsWith(publicPrefix + "/")) {
469
+ path = publicPrefix + (path.startsWith("/") ? path : `/${path}`);
470
+ }
471
+ next.location = path + location.search + location.hash;
472
+ }
473
+ } catch {}
474
  }
475
 
476
+ return next;
477
+ }
478
+
479
+ function sendServiceUnavailable(res) {
480
+ if (!res.headersSent) {
481
+ res.writeHead(503, { "Content-Type": "application/json" });
482
+ res.end(JSON.stringify({ status: "starting", message: "Service is initializing… please wait." }));
483
+ } else {
484
+ res.end();
 
 
 
485
  }
486
+ }
487
 
488
+ function proxyHTTP(req, res, targetHost, targetPort, options = {}) {
489
+ const url = parseRequestUrl(req.url);
490
+ const headers = {
491
  ...req.headers,
492
+ host: `${targetHost}:${targetPort}`,
493
  "x-forwarded-for": req.socket.remoteAddress,
494
  "x-forwarded-host": req.headers.host,
495
  "x-forwarded-proto": "https",
496
+ "x-forwarded-prefix": options.publicPrefix || "",
497
  };
498
 
499
+ const canReplayRequest = req.method === "GET" || req.method === "HEAD";
500
+ const proxyOnce = (path, retryOn404) => {
501
+ const pr = http.request({ hostname: targetHost, port: targetPort, path, method: req.method, headers }, (pres) => {
502
+ if (canReplayRequest && retryOn404 && pres.statusCode === 404 && options.stripPrefix) {
503
+ pres.resume();
504
+ return proxyOnce(proxiedPath(url, { stripPrefix: options.stripPrefix }), false);
505
+ }
506
+ res.writeHead(pres.statusCode, rewriteProxyHeaders(pres.headers, { ...options, targetHost, targetPort }));
507
+ pres.pipe(res);
508
+ pres.on("error", () => res.end());
509
+ });
510
+ req.on("error", () => pr.destroy());
511
+ res.on("error", () => pr.destroy());
512
+ pr.on("error", () => sendServiceUnavailable(res));
513
+ req.pipe(pr);
514
+ };
 
515
 
516
+ // First try the public path as-is because OpenClaw and JupyterLab are both
517
+ // configured with base paths. If a backend still returns 404, retry with the
518
+ // mount prefix stripped; that covers images built before the base-path config
519
+ // took effect and avoids the common HF Spaces "404 at /app or /terminal" trap.
520
+ proxyOnce(url.pathname + url.search, !!options.retryWithoutPrefixOn404);
521
+ }
522
 
523
+ // ── HTTP server ──
524
+ const server = http.createServer(async (req, res) => {
525
+ const { pathname } = parseRequestUrl(req.url);
526
+
527
+ // Lightweight endpoint for client-side fallback detection.
528
+ // Called by the dashboard JS if it suspects the server-rendered SPACE_IS_PRIVATE
529
+ // value was stale (race condition at startup). No auth required β€” it's not sensitive.
530
+ if (pathname === "/api/is-private") {
531
+ if (!_privacyDetectionDone) await privacyDetectionReady;
532
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
533
+ return res.end(JSON.stringify({ isPrivate: SPACE_IS_PRIVATE }));
534
+ }
535
+
536
+ if (pathname === "/health") {
537
+ const gatewayReady = await probePort(GATEWAY_HOST, GATEWAY_PORT, "/health");
538
+ res.writeHead(gatewayReady ? 200 : 503, { "Content-Type": "application/json" });
539
+ return res.end(JSON.stringify({ status: gatewayReady ? "ok" : "degraded", gatewayReady, uptime: formatUptime(Date.now() - startTime), sync: getSyncStatus(), keepalive: getKeepaliveStatus() }));
540
+ }
541
+
542
+ if (pathname === "/status") {
543
+ const [gatewayReady, jupyterReady] = await Promise.all([
544
+ probePort(GATEWAY_HOST, GATEWAY_PORT, "/health"),
545
+ JUPYTER_ENABLED ? probePort(JUPYTER_HOST, JUPYTER_PORT, `${JUPYTER_BASE}/login`) : Promise.resolve(false),
546
+ ]);
547
+ res.writeHead(200, { "Content-Type": "application/json" });
548
+ return res.end(JSON.stringify({ model: LLM_MODEL, uptime: formatUptime(Date.now() - startTime), gatewayReady, jupyterReady, sync: getSyncStatus(), whatsapp: readGuardianStatus(), keepalive: getKeepaliveStatus() }));
549
+ }
550
 
551
+ // Private space redirect β€” send users to the authenticated HF Spaces page.
552
+ // Works for both direct .hf.space links AND programmatic shares.
553
+ if (pathname === "/hf-redirect" || pathname === "/hf-redirect/") {
554
+ if (HF_SPACE_URL) {
555
+ res.writeHead(302, { Location: HF_SPACE_URL, "Cache-Control": "no-store" });
556
+ return res.end();
 
 
 
 
 
 
557
  }
558
+ res.writeHead(404, { "Content-Type": "text/plain" });
559
+ return res.end("SPACE_ID not configured.");
560
+ }
561
+
562
+ // ── Private Space Guard (server-side) ──
563
+ // Triggers automatically when SPACE_IS_PRIVATE=true (detected via HF API at startup).
564
+ // Only intercepts browser navigation (Accept: text/html) β€” API calls, assets,
565
+ // and WebSocket upgrades pass through untouched.
566
+ // /health and /status are always exempt so uptime monitors keep working.
567
+ const isHtmlRequest = (req.headers.accept || "").includes("text/html");
568
+
569
+ // RACE CONDITION FIX: Wait for privacy detection to finish BEFORE computing
570
+ // isDirectHfSpaceRequest. Previously this const was computed immediately with
571
+ // the fail-secure default (SPACE_IS_PRIVATE=true), causing private redirects
572
+ // even when the space is actually public or the owner is accessing via HF App.
573
+ // After the very first HTML request, _privacyDetectionDone=true so no delay.
574
+ if (isHtmlRequest && !_privacyDetectionDone) await privacyDetectionReady;
575
+
576
+ // In-app navigation (clicking links within the HF iframe) sends a Referer
577
+ // from the same .hf.space origin β€” don't redirect those, only redirect
578
+ // fresh direct browser access that has no same-origin referer.
579
+ const referer = req.headers.referer || req.headers.referrer || "";
580
+ const isSameOriginNav = !!(referer && typeof req.headers.host === "string" &&
581
+ referer.startsWith(`https://${req.headers.host}`));
582
+ // When HF App embeds the space in an iframe, the initial request has
583
+ // Referer: https://huggingface.co/spaces/... (NOT .hf.space).
584
+ // HF handles authentication itself β€” if the user is not logged in, HF
585
+ // redirects them before the iframe ever loads. So a huggingface.co referer
586
+ // means the user is already authenticated; skip the private redirect.
587
+ const isFromHFApp = !!(referer && (
588
+ referer.startsWith("https://huggingface.co") ||
589
+ referer.startsWith("https://hf.co")
590
+ ));
591
+ // NOTE: computed AFTER detection is awaited above β€” always uses real value.
592
+ const isDirectHfSpaceRequest = SPACE_IS_PRIVATE &&
593
+ HF_SPACE_URL &&
594
+ isHtmlRequest &&
595
+ typeof req.headers.host === "string" &&
596
+ req.headers.host.endsWith(".hf.space") &&
597
+ !isSameOriginNav &&
598
+ !isFromHFApp;
599
+
600
+ if (pathname === "/env-builder" || pathname === "/env-builder/") {
601
+ if (isDirectHfSpaceRequest) {
602
+ res.writeHead(200, { "Content-Type": "text/html" });
603
+ return res.end(renderPrivateRedirect(HF_SPACE_URL));
604
+ }
605
+ res.writeHead(200, { "Content-Type": "text/html" });
606
+ return res.end(renderEnvBuilder());
607
+ }
608
+
609
+ if (pathname === "/env-builder.js") {
610
+ try {
611
+ const js = fs.readFileSync(require("path").join(__dirname, "env-builder.js"), "utf8");
612
+ res.writeHead(200, { "Content-Type": "application/javascript" });
613
+ return res.end(js);
614
+ } catch (exc) {
615
+ res.writeHead(404, { "Content-Type": "text/plain" });
616
+ return res.end(`env-builder.js not found: ${exc.message}`);
617
+ }
618
+ }
619
+
620
+ if (pathname === "/" || pathname === "/dashboard") {
621
+ // Detection already awaited above (in the isHtmlRequest guard) β€” no extra wait needed.
622
+ if (isDirectHfSpaceRequest) {
623
+ res.writeHead(200, { "Content-Type": "text/html" });
624
+ return res.end(renderPrivateRedirect(HF_SPACE_URL));
625
+ }
626
+ const [gatewayReady, jupyterReady] = await Promise.all([
627
+ probePort(GATEWAY_HOST, GATEWAY_PORT, "/health"),
628
+ JUPYTER_ENABLED ? probePort(JUPYTER_HOST, JUPYTER_PORT, `${JUPYTER_BASE}/login`) : Promise.resolve(false),
629
+ ]);
630
+ res.writeHead(200, { "Content-Type": "text/html" });
631
+ return res.end(renderDashboard({ uptimeHuman: formatUptime(Date.now() - startTime), gatewayReady, jupyterReady, sync: getSyncStatus(), whatsapp: readGuardianStatus(), keepalive: getKeepaliveStatus() }));
632
+ }
633
 
634
+ // JupyterLab terminal
635
+ if (pathname === JUPYTER_BASE || pathname.startsWith(JUPYTER_BASE + "/")) {
636
+ if (!JUPYTER_ENABLED) {
637
+ res.writeHead(404, { "Content-Type": "application/json" });
638
+ return res.end(JSON.stringify({ status: "disabled", message: "JupyterLab terminal is disabled. Set DEV_MODE=true to enable /terminal/." }));
639
+ }
640
+ if (isDirectHfSpaceRequest) {
641
+ res.writeHead(200, { "Content-Type": "text/html" });
642
+ return res.end(renderPrivateRedirect(HF_SPACE_URL));
643
+ }
644
+ return proxyHTTP(req, res, JUPYTER_HOST, JUPYTER_PORT, {
645
+ publicPrefix: JUPYTER_BASE,
646
+ // Jupyter is started with --ServerApp.base_url=/terminal/, so keep the
647
+ // /terminal prefix when proxying. Stripping it breaks static/theme URLs.
648
+ stripPrefix: "",
649
+ retryWithoutPrefixOn404: false,
650
+ });
651
+ }
652
+
653
+ // OpenClaw Control UI mounted under /app. Retry without the mount prefix on
654
+ // 404 so deployments keep working across OpenClaw basePath behavior changes.
655
+ if (pathname === APP_BASE || pathname.startsWith(APP_BASE + "/")) {
656
+ if (isDirectHfSpaceRequest) {
657
+ res.writeHead(200, { "Content-Type": "text/html" });
658
+ return res.end(renderPrivateRedirect(HF_SPACE_URL));
659
+ }
660
+ return proxyHTTP(req, res, GATEWAY_HOST, GATEWAY_PORT, {
661
+ publicPrefix: APP_BASE,
662
+ stripPrefix: APP_BASE,
663
+ retryWithoutPrefixOn404: true,
664
+ });
665
+ }
666
+
667
+ // Favicon β€” serve a minimal inline SVG so browsers don't proxy to the gateway
668
+ if (pathname === "/favicon.ico" || pathname === "/favicon.svg") {
669
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🦞</text></svg>';
670
+ res.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=86400" });
671
+ return res.end(svg);
672
+ }
673
+
674
+ // OpenClaw gateway API/static fallback (everything else)
675
+ if (isDirectHfSpaceRequest) {
676
+ res.writeHead(200, { "Content-Type": "text/html" });
677
+ return res.end(renderPrivateRedirect(HF_SPACE_URL));
678
+ }
679
+ proxyHTTP(req, res, GATEWAY_HOST, GATEWAY_PORT);
680
  });
681
 
682
+ // ── WebSocket upgrade (JupyterLab kernels + terminals need this) ──
683
  server.on("upgrade", (req, socket, head) => {
684
+ const { pathname, search } = parseRequestUrl(req.url);
685
+ const isJupyter = JUPYTER_ENABLED && (pathname === JUPYTER_BASE || pathname.startsWith(JUPYTER_BASE + "/"));
686
+ const isApp = pathname === APP_BASE || pathname.startsWith(APP_BASE + "/");
687
+ const [targetHost, targetPort] = isJupyter ? [JUPYTER_HOST, JUPYTER_PORT] : [GATEWAY_HOST, GATEWAY_PORT];
688
+ const publicPrefix = isJupyter ? JUPYTER_BASE : isApp ? APP_BASE : "";
689
+ const targetPath = pathname + search;
690
+
691
+ const ps = net.connect(targetPort, targetHost, () => {
692
+ ps.write(`${req.method} ${targetPath} HTTP/${req.httpVersion}\r\n`);
693
+ ps.write(`Host: ${targetHost}:${targetPort}\r\n`);
694
+ ps.write(`X-Forwarded-For: ${req.socket.remoteAddress || ""}\r\n`);
695
+ ps.write(`X-Forwarded-Host: ${req.headers.host || ""}\r\n`);
696
+ ps.write("X-Forwarded-Proto: https\r\n");
697
+ if (publicPrefix) ps.write(`X-Forwarded-Prefix: ${publicPrefix}\r\n`);
698
  for (let i = 0; i < req.rawHeaders.length; i += 2) {
699
+ const header = req.rawHeaders[i];
700
+ const lower = header.toLowerCase();
701
+ if (["host", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "x-forwarded-prefix"].includes(lower)) continue;
702
+ ps.write(`${header}: ${req.rawHeaders[i + 1]}\r\n`);
703
  }
704
+ ps.write("\r\n");
705
+ if (head && head.length) ps.write(head);
706
+ ps.pipe(socket).pipe(ps);
707
  });
708
+ ps.on("error", () => socket.destroy());
709
+ ps.on("close", () => socket.destroy());
710
+ socket.on("error", () => ps.destroy());
711
+ socket.on("close", () => ps.destroy());
712
  });
713
 
714
  server.timeout = 0;
715
  server.keepAliveTimeout = 65000;
716
+ server.on("error", (err) => console.error(`[health-server] Server error:`, err));
717
  server.listen(PORT, "0.0.0.0", () =>
718
+ console.log(`🦞 HuggingClaw :${PORT} β†’ Gateway :${GATEWAY_PORT}${JUPYTER_ENABLED ? ` | Terminal :${JUPYTER_PORT} at ${JUPYTER_BASE}/` : " | Terminal disabled"}`),
 
 
719
  );
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
+ <p style="color:#666;">Enter the <strong>JUPYTER_TOKEN</strong> you set in your Space secrets to access the terminal.</p>
12
+ <p style="color:#666;">This terminal is mounted at <code>/terminal/</code> inside the same Hugging Face Space as the OpenClaw UI.</p>
13
+
14
+ {% if login_available %}
15
+ <div class="row" style="display:flex; justify-content:center; margin-top:24px;">
16
+ <div class="navbar col-sm-8">
17
+ <div class="navbar-inner">
18
+ <div class="container">
19
+ <div class="center-nav">
20
+ <form action="{{base_url}}login?next={{next}}" method="post" class="navbar-form pull-left">
21
+ {{ xsrf_form_html() | safe }}
22
+ {% if token_available %}
23
+ <label for="password_input"><strong>{% trans %}Jupyter token <span title="This is the secret you set up when deploying your JupyterLab terminal">β“˜</span> {% endtrans %}</strong></label>
24
+ {% else %}
25
+ <label for="password_input"><strong>{% trans %}Jupyter password:{% endtrans %}</strong></label>
26
+ {% endif %}
27
+ <input type="password" name="password" id="password_input" class="form-control">
28
+ <button type="submit" class="btn btn-default" id="login_submit">{% trans %}Log in{% endtrans %}</button>
29
+ </form>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ {% else %}
36
+ <p>{% trans %}No login available, you shouldn't be seeing this page.{% endtrans %}</p>
37
+ {% endif %}
38
+
39
+ <h5 style="margin-top:28px;"><a href="/dashboard">Back to HuggingClaw dashboard</a></h5>
40
+ <p>This login page is based on the Hugging Face JupyterLab Space template.</p>
41
+
42
+ {% if message %}
43
+ <div class="row">
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 ──
@@ -368,8 +458,10 @@ inject_provider_models_from_env() {
368
  CONFIG_JSON=$(jq \
369
  --arg provider "$provider" \
370
  --argjson models "$models_json" \
371
- '.models.mode = "merge"
372
- | .models.providers[$provider] = ((.models.providers[$provider] // {}) + {models: $models})' <<<"$CONFIG_JSON")
 
 
373
  }
374
 
375
  # Built-in provider model envs (optional)
@@ -433,6 +525,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 +546,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 +604,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 +634,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 +703,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 +758,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
 
@@ -705,10 +840,124 @@ warmup_browser() {
705
 
706
  # ── Start background services ──
707
  export LLM_MODEL="$LLM_MODEL"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
708
  # 10. Start Health Server & Dashboard
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 +977,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 +1011,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 +1084,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 +1111,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 +1133,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 +1199,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 +1267,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 +1291,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 +1409,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))
@@ -1104,6 +1437,9 @@ if [ -n "${HUGGINGCLAW_OPENCLAW_PLUGINS:-}" ]; then
1104
  fi
1105
  fi
1106
 
 
 
 
1107
  # ── Arbitrary startup commands from HF Variables/Secrets ──
1108
  # Recommended: use one variable, HUGGINGCLAW_RUN, as a full bash script. If the
1109
  # value starts with base64: or b64:, the rest is decoded and run as the script.
@@ -1165,6 +1501,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 +1540,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 +1557,31 @@ 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)
@@ -1225,7 +1618,9 @@ while true; do
1225
  echo "Gateway failed to start. Last 30 lines of log:"
1226
  echo "────────────────────────────────────────────"
1227
  tail -30 /home/node/.openclaw/gateway.log
1228
- exit 1
 
 
1229
  fi
1230
 
1231
  # 11. Start WhatsApp Guardian after the gateway is accepting connections
@@ -1238,6 +1633,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"
@@ -1249,7 +1645,8 @@ while true; do
1249
  GATEWAY_RESTART_COUNT=$((GATEWAY_RESTART_COUNT + 1))
1250
  if [ "$GATEWAY_MAX_RESTARTS" != "0" ] && [ "$GATEWAY_RESTART_COUNT" -ge "$GATEWAY_MAX_RESTARTS" ]; then
1251
  echo "Gateway exited with code ${GATEWAY_EXIT_CODE}; restart limit (${GATEWAY_MAX_RESTARTS}) reached."
1252
- exit "$GATEWAY_EXIT_CODE"
 
1253
  fi
1254
 
1255
  echo "Gateway exited with code ${GATEWAY_EXIT_CODE}; restarting in ${GATEWAY_RESTART_DELAY}s..."
 
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 ──
 
458
  CONFIG_JSON=$(jq \
459
  --arg provider "$provider" \
460
  --argjson models "$models_json" \
461
+ 'if .models.providers[$provider] then
462
+ .models.mode = "merge"
463
+ | .models.providers[$provider].models = $models
464
+ else . end' <<<"$CONFIG_JSON")
465
  }
466
 
467
  # Built-in provider model envs (optional)
 
525
  # plugins that the Control UI/dashboard needs to render correctly
526
  # on HF Spaces. Without these the UI shows blank panels.
527
  # telegram/whatsapp/browser/acpx are added conditionally below.
528
+ # Do not create a disabled acpx entry when the plugin is absent;
529
+ # OpenClaw reports that as a config warning on HF Spaces.
530
  # DENY: lmstudio crashes on boot when no local server is reachable;
531
  # xai PLUGIN (separate from the xai model PROVIDER) is broken in
532
  # current OpenClaw releases and prevents gateway start. Disabling
 
546
  fi
547
 
548
  # Apply plugin allow/deny + per-entry toggles in one jq pass.
 
 
549
  BROWSER_DISABLED=true
550
  if [ "$BROWSER_SHOULD_ENABLE" = "true" ]; then BROWSER_DISABLED=false; fi
551
 
552
  CONFIG_JSON=$(jq \
553
  --argjson allow "$PLUGIN_ALLOW_JSON" \
 
554
  --argjson browserDisabled "$BROWSER_DISABLED" \
555
  '.plugins.allow = $allow
556
  | .plugins.deny = ["lmstudio","xai"]
557
  | .plugins.entries.lmstudio.enabled = false
558
  | .plugins.entries.xai.enabled = false
559
+ | del(.plugins.entries.acpx)
560
  | (if $browserDisabled then
561
  .plugins.entries.browser.enabled = false | .browser.enabled = false
562
  else . end)' <<<"$CONFIG_JSON")
 
604
  CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".gateway.controlUi.allowedOrigins += $ORIGINS_JSON | .gateway.controlUi.allowedOrigins |= unique")
605
  fi
606
 
607
+ resolve_telegram_api_root() {
608
+ local candidate="$(trim_var "${CLOUDFLARE_PROXY_URL:-}")"
609
+ if [ -n "$candidate" ]; then
610
+ case "$candidate" in
611
+ http://*|https://*)
612
+ printf '%s' "$candidate"
613
+ return 0
614
+ ;;
615
+ *)
616
+ echo "Warning: invalid CLOUDFLARE_PROXY_URL '$candidate' (must start with http:// or https://); falling back to direct Telegram API." >&2
617
+ ;;
618
+ esac
619
+ fi
620
+ printf '%s' "https://api.telegram.org"
621
+ }
622
+ TELEGRAM_API_ROOT="$(resolve_telegram_api_root)"
623
+
624
+
625
  # Telegram (supports multiple user IDs, comma-separated)
626
  if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
627
  CONFIG_JSON=$(echo "$CONFIG_JSON" | jq '.plugins.entries.telegram = {"enabled": true}')
 
634
  # Force ipv4 for Telegram specifically as HF IPv6 often times out
635
  export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--dns-result-order=ipv4first"
636
 
637
+ CONFIG_JSON=$(echo "$CONFIG_JSON" | jq --arg token "$CLEAN_TG_TOKEN" --arg proxy_url "$TELEGRAM_API_ROOT" '
638
  .channels.telegram.enabled = true
639
  | .channels.telegram.botToken = $token
640
  | .channels.telegram.commands.native = false
641
  | .channels.telegram.timeoutSeconds = 60
642
+ | (if $proxy_url != "" then .channels.telegram.apiRoot = $proxy_url else . end)
643
  | .channels.telegram.retry = {
644
  "attempts": 5,
645
  "minDelayMs": 800,
 
703
  | .channels = ((.channels // {}) * ($desired.channels // {}))
704
  | .plugins.allow = (((.plugins.allow // []) + ($desired.plugins.allow // [])) | unique)
705
  | .plugins.deny = (((.plugins.deny // []) + ($desired.plugins.deny // [])) | unique)
706
+ | .plugins.entries = ((.plugins.entries // {}) * ($desired.plugins.entries // {}))
707
  | if $whatsappEnabled then
708
  ($desired.channels.whatsapp // {"dmPolicy": "pairing"}) as $desiredWhatsapp
709
  | .plugins.entries.whatsapp.enabled = true
 
758
  if [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
759
  echo "Proxy : ${CLOUDFLARE_PROXY_URL}"
760
  fi
761
+ RUNTIME_JUPYTER_ENABLED="$DEV_MODE_ENABLED"
762
+ # Add user bin to PATH for jupyter-lab (installed in Dockerfile when DEV_MODE=true)
763
+ export PATH="$HOME/.local/bin:$PATH"
764
+
765
+ # Runtime install fallback: only attempt if DEV_MODE is enabled but install failed during build
766
+ if [ "$DEV_MODE_ENABLED" = "true" ] && ! python3 -c "import jupyterlab" >/dev/null 2>&1; then
767
+ echo "DEV_MODE enabled but jupyter-lab is missing; attempting runtime install..."
768
+ if python3 -m pip install --user --no-cache-dir --break-system-packages "jupyterlab>=4.2,<5" "tornado>=6.3" "ipywidgets>=8.1"; then
769
+ echo "Runtime Jupyter install complete."
770
+ 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
771
+ else
772
+ echo "WARNING: Runtime Jupyter install failed; disabling terminal for this boot."
773
+ RUNTIME_JUPYTER_ENABLED=false
774
+ fi
775
+ fi
776
+ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ] && ! python3 -c "import jupyterlab" >/dev/null 2>&1; then
777
+ echo "WARNING: jupyter-lab still unavailable; disabling terminal for this boot."
778
+ RUNTIME_JUPYTER_ENABLED=false
779
+ fi
780
+ export HUGGINGCLAW_JUPYTER_ENABLED="$RUNTIME_JUPYTER_ENABLED"
781
+
782
  if [ -n "${SPACE_HOST:-}" ]; then
783
+ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ]; then
784
+ echo "Routes : /app/ (Control UI), /terminal/ (JupyterLab)"
785
+ else
786
+ echo "Routes : /app/ (Control UI)"
787
+ fi
788
+ echo "Private : open the Hugging Face App tab first; raw https://${SPACE_HOST}/... links can show HF 404 without the embedded Space session."
789
  fi
790
  echo ""
791
 
 
840
 
841
  # ── Start background services ──
842
  export LLM_MODEL="$LLM_MODEL"
843
+
844
+ # ── Ensure key-rotator uses the correct HF token for huggingface.co calls ──
845
+ # NODE_OPTIONS preloads multi-provider-key-rotator.cjs into health-server.js.
846
+ # The rotator patches https.request and injects HUGGINGFACE_HUB_TOKEN (or
847
+ # falls back to LLM_API_KEY) for any call to huggingface.co β€” including the
848
+ # privacy-detection API call in detectSpacePrivacy(). If HUGGINGFACE_HUB_TOKEN
849
+ # is not set (user's LLM provider is not HuggingFace), the rotator falls back
850
+ # to LLM_API_KEY, which is the AI-provider key, NOT the HF owner token.
851
+ # This causes a 401 on /api/spaces/${SPACE_ID} β†’ privacy detection always
852
+ # fails β†’ SPACE_IS_PRIVATE stays true β†’ public-space links never open in a
853
+ # new tab.
854
+ # Fix: seed HUGGINGFACE_HUB_TOKEN from HF_TOKEN when not already set.
855
+ # HF Spaces auto-injects HF_TOKEN as the space owner's token, so this is safe.
856
+ export HUGGINGFACE_HUB_TOKEN="${HUGGINGFACE_HUB_TOKEN:-${HF_TOKEN:-}}"
857
+
858
  # 10. Start Health Server & Dashboard
859
  node /home/node/app/health-server.js &
860
  HEALTH_PID=$!
861
 
862
+ start_jupyter_once() {
863
+ [ "$RUNTIME_JUPYTER_ENABLED" = "true" ] || return 0
864
+ if [ -n "${JUPYTER_PID:-}" ] && kill -0 "$JUPYTER_PID" 2>/dev/null; then
865
+ return 0
866
+ fi
867
+
868
+ # Security guard: refuse to start JupyterLab with the insecure default token.
869
+ # JupyterLab exposes a full shell β€” a weak token is equivalent to no auth.
870
+ if [ -z "${JUPYTER_TOKEN:-}" ] || [ "${JUPYTER_TOKEN}" = "huggingface" ]; then
871
+ echo "ERROR: JUPYTER_TOKEN is unset or still set to the insecure default (\"huggingface\")." >&2
872
+ echo " JupyterLab grants full shell access. Set a strong, unique token in your Space secrets." >&2
873
+ echo " Hint: openssl rand -hex 32" >&2
874
+ echo " DEV_MODE active but JupyterLab will NOT start until JUPYTER_TOKEN is changed." >&2
875
+ return 1
876
+ fi
877
+ JUPYTER_ROOT_DIR="${JUPYTER_ROOT_DIR:-/home/node}"
878
+ if [ "$JUPYTER_ROOT_DIR" = "/home/node/.openclaw/workspace" ] && [ "$DEVDATA_ENABLED" = "true" ]; then
879
+ echo "Jupyter root was set to OpenClaw workspace; moving Jupyter root to /home/node/devdata to keep BACKUP and DEVDATA datasets separate."
880
+ JUPYTER_ROOT_DIR="/home/node/devdata"
881
+ fi
882
+ mkdir -p "$JUPYTER_ROOT_DIR"
883
+ export JUPYTER_ROOT_DIR
884
+ if [ "$JUPYTER_ROOT_DIR" != "/home/node/app" ]; then
885
+ if [ -L "$JUPYTER_ROOT_DIR/HuggingClaw" ] || [ ! -e "$JUPYTER_ROOT_DIR/HuggingClaw" ]; then
886
+ ln -sfn /home/node/app "$JUPYTER_ROOT_DIR/HuggingClaw"
887
+ fi
888
+ fi
889
+ if [ "$JUPYTER_ROOT_DIR" != "/home/node/.openclaw/workspace" ]; then
890
+ if [ -L "$JUPYTER_ROOT_DIR/HuggingClaw-Workspace" ] || [ ! -e "$JUPYTER_ROOT_DIR/HuggingClaw-Workspace" ]; then
891
+ ln -sfn /home/node/.openclaw/workspace "$JUPYTER_ROOT_DIR/HuggingClaw-Workspace"
892
+ fi
893
+ fi
894
+
895
+ # Pre-create runtime directory
896
+ mkdir -p "$JUPYTER_ROOT_DIR/.jupyter"
897
+
898
+ echo "DEV_MODE enabled (${DEV_MODE_RAW}) β€” starting JupyterLab terminal on internal port 8888 (path: /terminal/) with root: $JUPYTER_ROOT_DIR"
899
+ JUPYTER_LOG_FILE="/tmp/jupyterlab.log"
900
+
901
+ # Use explicit Python to avoid PATH issues; set memory-friendly limits
902
+ export PYTHONPATH=""
903
+ python3 -m jupyterlab \
904
+ --ip 127.0.0.1 \
905
+ --port 8888 \
906
+ --no-browser \
907
+ --IdentityProvider.token="$JUPYTER_TOKEN" \
908
+ --ServerApp.base_url=/terminal/ \
909
+ --ServerApp.terminals_enabled=True \
910
+ --ServerApp.terminado_settings='{"shell_command":["/bin/bash","-i"]}' \
911
+ --ServerApp.allow_origin='*' \
912
+ --ServerApp.allow_remote_access=True \
913
+ --ServerApp.trust_xheaders=True \
914
+ --ServerApp.tornado_settings="{'headers': {'Content-Security-Policy': 'frame-ancestors *'}}" \
915
+ --IdentityProvider.cookie_options="{'SameSite': 'None', 'Secure': True}" \
916
+ --ServerApp.disable_check_xsrf=True \
917
+ --LabApp.news_url=None \
918
+ --LabApp.check_for_updates_class=jupyterlab.NeverCheckForUpdate \
919
+ --ServerApp.log_level=WARN \
920
+ --ServerApp.root_dir="$JUPYTER_ROOT_DIR" \
921
+ >> "$JUPYTER_LOG_FILE" 2>&1 &
922
+ JUPYTER_PID=$!
923
+ export JUPYTER_PID
924
+ echo "JupyterLab started (PID: $JUPYTER_PID)"
925
+ }
926
+
927
+ # BUG FIX #3: DevData restore must happen BEFORE JupyterLab starts.
928
+ # The background jupyter-devdata-sync.py process is only launched AFTER the
929
+ # gateway is ready (20-90 s from now). If restore ran there, JupyterLab would
930
+ # already be live and the file writes would corrupt its runtime state β†’ crash.
931
+ # Running --restore here (synchronous, before JupyterLab) solves that.
932
+ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ] && \
933
+ [ "$DEVDATA_ENABLED" = "true" ] && \
934
+ [ -n "${HF_TOKEN:-}" ] && \
935
+ [ -f "/home/node/app/jupyter-devdata-sync.py" ] && \
936
+ [ "${DEVDATA_DATASET_NAME:-huggingclaw-devdata}" != "${BACKUP_DATASET_NAME:-huggingclaw-backup}" ]; then
937
+ echo "DevData : restoring workspace from ${DEVDATA_DATASET_NAME:-huggingclaw-devdata} (before JupyterLab starts)..."
938
+ python3 /home/node/app/jupyter-devdata-sync.py --restore || \
939
+ echo "DevData : restore warning (non-fatal); continuing startup."
940
+ fi
941
+
942
+ # Fix: reinstall jsonschema AFTER devdata restore β€” restore can overwrite a broken
943
+ # version from .local/lib/python3.11/site-packages into the workspace, causing
944
+ # JupyterLab to crash with a circular import error on every boot.
945
+ if [ "$DEV_MODE_ENABLED" = "true" ]; then
946
+ if ! python3 -c "import jsonschema" >/dev/null 2>&1; then
947
+ echo "DevData : jsonschema broken after restore β€” reinstalling (circular import fix)..."
948
+ python3 -m pip install --force-reinstall --no-cache-dir --break-system-packages "jsonschema>=4.0" >/dev/null 2>&1 || true
949
+ echo "DevData : jsonschema reinstall done."
950
+ fi
951
+ fi
952
+
953
+ # 10.5. Start JupyterLab Terminal on internal port 8888 (DEV_MODE only)
954
+ # Accessible via /terminal/ path through the health-server proxy
955
+ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ]; then
956
+ start_jupyter_once
957
+ else
958
+ echo "Jupyter terminal disabled for this boot (DEV_MODE=${DEV_MODE_RAW})."
959
+ fi
960
+
961
  if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then
962
  echo "Setting up Cloudflare KeepAlive monitor..."
963
  python3 /home/node/app/cloudflare-keepalive-setup.py || true
 
977
  export npm_config_prefix="$NPM_CONFIG_PREFIX"
978
  export PYTHONUSERBASE="${PYTHONUSERBASE:-/home/node/.local}"
979
  export DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"
980
+ if [ -z "${PS1:-}" ] || [ "$PS1" = "$ " ]; then
981
+ export PS1="\u@\h:\w\$ "
982
+ fi
983
  STARTUP_FILE="/home/node/.openclaw/workspace/startup.sh"
984
  _hc_append() {
985
+ if [ "${HUGGINGCLAW_CAPTURE_DISABLE:-0}" = "1" ]; then
986
+ return 0
987
+ fi
988
  local line="$*"
989
  mkdir -p "$(dirname "$STARTUP_FILE")"
990
  touch "$STARTUP_FILE"
 
1011
  _hc_append "$cmd"
1012
  fi
1013
  }
1014
+ _hc_args_without_flags() {
1015
+ local out=()
1016
+ local arg
1017
+ for arg in "$@"; do
1018
+ case "$arg" in
1019
+ ''|-) ;;
1020
+ --*) ;;
1021
+ -*) ;;
1022
+ *) out+=("$arg") ;;
1023
+ esac
1024
+ done
1025
+ printf '%s\n' "${out[@]}"
1026
+ }
1027
+ _hc_has_install_targets() {
1028
+ local item
1029
+ while IFS= read -r item; do
1030
+ [ -n "$item" ] && return 0
1031
+ done <<EOF
1032
+ $(_hc_args_without_flags "$@")
1033
+ EOF
1034
+ return 1
1035
+ }
1036
  _hc_allow_openclaw_plugins() {
1037
  local config="/home/node/.openclaw/openclaw.json"
1038
  [ -f "$config" ] || return 0
 
1084
  _hc_apt_install "$@"
1085
  local rc=$?
1086
  if [ $rc -eq 0 ]; then
1087
+ _hc_has_install_targets "$@" && _hc_append_cmd "sudo apt-get update && sudo apt-get install -y" "$@"
1088
  fi
1089
  return $rc
1090
  ;;
 
1111
  _hc_apt_install "$@"
1112
  local rc=$?
1113
  if [ $rc -eq 0 ]; then
1114
+ _hc_has_install_targets "$@" && _hc_append_cmd "sudo apt-get update && sudo apt-get install -y" "$@"
1115
  fi
1116
  return $rc
1117
  ;;
 
1133
  }
1134
  pip() {
1135
  if [ "${1:-}" = "install" ] && [ -z "${VIRTUAL_ENV:-}" ] && ! _hc_has_arg --user "$@" && ! _hc_has_arg --prefix "$@"; then
1136
+ command pip install --user --break-system-packages "${@:2}"
1137
  else
1138
  command pip "$@"
1139
  fi
1140
  local rc=$?
1141
+ # Skip capture when -r/--requirement is used: the requirements file won't exist on next boot
1142
+ if [ $rc -eq 0 ] && [ "${1:-}" = "install" ] \
1143
+ && ! _hc_has_arg -r "${@:2}" && ! _hc_has_arg --requirement "${@:2}" \
1144
+ && _hc_has_install_targets "${@:2}"; then
1145
  _hc_append_cmd "python3 -m pip install --user" "${@:2}"
1146
  fi
1147
  return $rc
1148
  }
1149
  pip3() {
1150
  if [ "${1:-}" = "install" ] && [ -z "${VIRTUAL_ENV:-}" ] && ! _hc_has_arg --user "$@" && ! _hc_has_arg --prefix "$@"; then
1151
+ command pip3 install --user --break-system-packages "${@:2}"
1152
  else
1153
  command pip3 "$@"
1154
  fi
1155
  local rc=$?
1156
+ if [ $rc -eq 0 ] && [ "${1:-}" = "install" ] \
1157
+ && ! _hc_has_arg -r "${@:2}" && ! _hc_has_arg --requirement "${@:2}" \
1158
+ && _hc_has_install_targets "${@:2}"; then
1159
  _hc_append_cmd "python3 -m pip install --user" "${@:2}"
1160
  fi
1161
  return $rc
1162
  }
1163
+ python() {
1164
+ if [ "${1:-}" = "-m" ] && [ "${2:-}" = "pip" ] && [ "${3:-}" = "install" ] && [ -z "${VIRTUAL_ENV:-}" ] && ! _hc_has_arg --user "${@:3}" && ! _hc_has_arg --prefix "${@:3}"; then
1165
+ command python -m pip install --user --break-system-packages "${@:4}"
1166
+ else
1167
+ command python "$@"
1168
+ fi
1169
+ local rc=$?
1170
+ if [ $rc -eq 0 ] && [ "${1:-}" = "-m" ] && [ "${2:-}" = "pip" ] && [ "${3:-}" = "install" ] \
1171
+ && ! _hc_has_arg -r "${@:4}" && ! _hc_has_arg --requirement "${@:4}" \
1172
+ && _hc_has_install_targets "${@:4}"; then
1173
+ _hc_append_cmd "python3 -m pip install --user" "${@:4}"
1174
+ fi
1175
+ return $rc
1176
+ }
1177
+ python3() {
1178
+ if [ "${1:-}" = "-m" ] && [ "${2:-}" = "pip" ] && [ "${3:-}" = "install" ] && [ -z "${VIRTUAL_ENV:-}" ] && ! _hc_has_arg --user "${@:3}" && ! _hc_has_arg --prefix "${@:3}"; then
1179
+ command python3 -m pip install --user --break-system-packages "${@:4}"
1180
+ else
1181
+ command python3 "$@"
1182
+ fi
1183
+ local rc=$?
1184
+ if [ $rc -eq 0 ] && [ "${1:-}" = "-m" ] && [ "${2:-}" = "pip" ] && [ "${3:-}" = "install" ] \
1185
+ && ! _hc_has_arg -r "${@:4}" && ! _hc_has_arg --requirement "${@:4}" \
1186
+ && _hc_has_install_targets "${@:4}"; then
1187
+ _hc_append_cmd "python3 -m pip install --user" "${@:4}"
1188
+ fi
1189
+ return $rc
1190
+ }
1191
  npm() {
1192
  command npm "$@"
1193
  local rc=$?
1194
+ if [ $rc -eq 0 ] && { [ "${1:-}" = "install" ] || [ "${1:-}" = "i" ]; } && { [ "${2:-}" = "-g" ] || [ "${2:-}" = "--global" ]; } && _hc_has_install_targets "${@:3}"; then
1195
  _hc_append_cmd "npm install -g" "${@:3}"
1196
  fi
1197
  return $rc
 
1199
  openclaw() {
1200
  command openclaw "$@"
1201
  local rc=$?
1202
+ if [ $rc -eq 0 ] && [ "${1:-}" = "plugins" ] && [ "${2:-}" = "install" ] && _hc_has_install_targets "${@:3}"; then
1203
  _hc_allow_openclaw_plugins "${@:3}"
1204
  _hc_append_cmd "openclaw plugins install" "${@:3}"
1205
  fi
1206
  return $rc
1207
  }
1208
+ # uv pip install β€” increasingly popular fast pip replacement
1209
+ uv() {
1210
+ command uv "$@"
1211
+ local rc=$?
1212
+ # Only capture: uv pip install ... (not uv pip sync, uv add, etc.)
1213
+ # Skip if -r/--requirements flag present (file won't exist on next boot)
1214
+ if [ $rc -eq 0 ] && [ "${1:-}" = "pip" ] && [ "${2:-}" = "install" ] \
1215
+ && ! _hc_has_arg -r "${@:3}" && ! _hc_has_arg --requirements "${@:3}" \
1216
+ && _hc_has_install_targets "${@:3}"; then
1217
+ _hc_append_cmd "uv pip install" "${@:3}"
1218
+ fi
1219
+ return $rc
1220
+ }
1221
+ # pipx β€” isolated tool installs
1222
+ pipx() {
1223
+ command pipx "$@"
1224
+ local rc=$?
1225
+ if [ $rc -eq 0 ] && [ "${1:-}" = "install" ] && _hc_has_install_targets "${@:2}"; then
1226
+ _hc_append_cmd "pipx install" "${@:2}"
1227
+ fi
1228
+ return $rc
1229
+ }
1230
  BASHRC
1231
  cat > /home/node/.profile <<'PROFILE'
1232
+ [ -n "${BASH_VERSION:-}" ] && [ -f ~/.bashrc ] && . ~/.bashrc
1233
  PROFILE
1234
  echo "Shell capture wrappers ready."
1235
 
 
1267
 
1268
  echo "[startup:${source_label}] $command_text"
1269
  set +e
1270
+ HUGGINGCLAW_CAPTURE_DISABLE=1 bash -lc "$command_text"
1271
  local rc=$?
1272
  set -e
1273
  if [ "$rc" -eq 0 ]; then
 
1291
  # Load HuggingClaw's install wrappers for env-provided scripts too, so
1292
  # `apt install`, `pip install`, `npm install -g`, and OpenClaw plugin
1293
  # installs behave the same way as they do in the interactive shell.
1294
+ echo 'export HUGGINGCLAW_CAPTURE_DISABLE=1'
1295
  echo '[ -f /home/node/.bashrc ] && . /home/node/.bashrc'
1296
  printf '%s\n' "$script_text"
1297
  } > "$script_file"
 
1409
  if [ -n "${HUGGINGCLAW_PIP_PACKAGES:-}" ]; then
1410
  echo "Installing Python packages from HUGGINGCLAW_PIP_PACKAGES..."
1411
  read -r -a HC_PIP_PACKAGES <<< "$HUGGINGCLAW_PIP_PACKAGES"
1412
+ if python3 -m pip install --user --break-system-packages "${HC_PIP_PACKAGES[@]}"; then
1413
  echo "HUGGINGCLAW_PIP_PACKAGES install complete."
1414
  else
1415
  HC_STARTUP_FAILURES=$((HC_STARTUP_FAILURES + 1))
 
1437
  fi
1438
  fi
1439
 
1440
+ # ── Fix config before running startup commands ──
1441
+ openclaw doctor --fix || true
1442
+
1443
  # ── Arbitrary startup commands from HF Variables/Secrets ──
1444
  # Recommended: use one variable, HUGGINGCLAW_RUN, as a full bash script. If the
1445
  # value starts with base64: or b64:, the rest is decoded and run as the script.
 
1501
  echo "Warning: could not sync settled state before gateway restart"
1502
  }
1503
 
1504
+ start_background_devdata_sync() {
1505
+ if [ "$DEV_MODE_ENABLED" != "true" ]; then
1506
+ return 0
1507
+ fi
1508
+ if [ "$DEVDATA_ENABLED" != "true" ]; then
1509
+ echo "DevData : disabled by DEVDATA=${DEVDATA_RAW}"
1510
+ return 0
1511
+ fi
1512
+ if [ -z "${HF_TOKEN:-}" ]; then
1513
+ echo "DevData : disabled (HF_TOKEN missing)"
1514
+ return 0
1515
+ fi
1516
+ if [ "${DEVDATA_DATASET_NAME:-huggingclaw-devdata}" = "${BACKUP_DATASET_NAME:-huggingclaw-backup}" ]; then
1517
+ echo "DevData : disabled (DEVDATA_DATASET_NAME must be separate from BACKUP_DATASET_NAME)"
1518
+ return 0
1519
+ fi
1520
+ if [ ! -f "/home/node/app/jupyter-devdata-sync.py" ]; then
1521
+ echo "DevData : script missing; skipped"
1522
+ return 0
1523
+ fi
1524
+ # BUG FIX #1: Guard against spawning a second devdata-sync process on every
1525
+ # gateway restart. Without this check, each restart launched a fresh
1526
+ # jupyter-devdata-sync.py which called restore_once() while JupyterLab was
1527
+ # already running, corrupting its runtime state and killing it.
1528
+ if [ -n "${DEVDATA_SYNC_PID:-}" ] && kill -0 "$DEVDATA_SYNC_PID" 2>/dev/null; then
1529
+ return 0
1530
+ fi
1531
+ echo "DevData : enabled (dataset=${DEVDATA_DATASET_NAME:-huggingclaw-devdata})"
1532
+ python3 -u /home/node/app/jupyter-devdata-sync.py >> /tmp/devdata-sync.log 2>&1 &
1533
+ DEVDATA_SYNC_PID=$!
1534
+ }
1535
+
1536
  start_background_sync_once() {
1537
  [ -n "${HF_TOKEN:-}" ] || return 0
1538
 
 
1540
  return 0
1541
  fi
1542
 
1543
+ python3 -u /home/node/app/openclaw-sync.py loop >> /tmp/workspace-sync.log 2>&1 &
1544
  SYNC_LOOP_PID=$!
1545
  }
1546
 
 
1557
  }
1558
 
1559
  while true; do
1560
+ # Check health-server process - restart if died unexpectedly
1561
+ if [ -n "${HEALTH_PID:-}" ] && ! kill -0 "$HEALTH_PID" 2>/dev/null; then
1562
+ echo "Warning: health-server exited (PID $HEALTH_PID dead); restarting..."
1563
+ node /home/node/app/health-server.js &
1564
+ HEALTH_PID=$!
1565
+ echo "Health server restarted (PID: $HEALTH_PID)"
1566
+ fi
1567
+
1568
+ # Check JupyterLab process - restart if died unexpectedly
1569
+ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ]; then
1570
+ if [ -n "${JUPYTER_PID:-}" ]; then
1571
+ if ! kill -0 "$JUPYTER_PID" 2>/dev/null; then
1572
+ echo "Warning: JupyterLab exited (PID $JUPYTER_PID dead); checking log..."
1573
+ tail -5 /tmp/jupyterlab.log 2>/dev/null || echo "No log file"
1574
+ echo "Attempting JupyterLab restart..."
1575
+ unset JUPYTER_PID
1576
+ start_jupyter_once
1577
+ fi
1578
+ else
1579
+ # First start
1580
+ start_jupyter_once
1581
+ fi
1582
+ fi
1583
+
1584
+ openclaw doctor --fix || true
1585
  echo "Launching OpenClaw gateway on port 7860..."
1586
 
1587
  GATEWAY_ARGS=(gateway run --port 7860 --bind lan)
 
1618
  echo "Gateway failed to start. Last 30 lines of log:"
1619
  echo "────────────────────────────────────────────"
1620
  tail -30 /home/node/.openclaw/gateway.log
1621
+ echo "Gateway failed β€” JupyterLab and env-builder still running. Retrying in 10s..."
1622
+ sleep 10
1623
+ continue
1624
  fi
1625
 
1626
  # 11. Start WhatsApp Guardian after the gateway is accepting connections
 
1633
  # config edits can make OpenClaw exit/reload, and the gateway loop below will
1634
  # relaunch it without rerunning all startup code.
1635
  start_background_sync_once
1636
+ start_background_devdata_sync
1637
 
1638
  set +e
1639
  wait "$GATEWAY_PID"
 
1645
  GATEWAY_RESTART_COUNT=$((GATEWAY_RESTART_COUNT + 1))
1646
  if [ "$GATEWAY_MAX_RESTARTS" != "0" ] && [ "$GATEWAY_RESTART_COUNT" -ge "$GATEWAY_MAX_RESTARTS" ]; then
1647
  echo "Gateway exited with code ${GATEWAY_EXIT_CODE}; restart limit (${GATEWAY_MAX_RESTARTS}) reached."
1648
+ echo "Gateway stopped β€” JupyterLab and env-builder still running."
1649
+ break
1650
  fi
1651
 
1652
  echo "Gateway exited with code ${GATEWAY_EXIT_CODE}; restarting in ${GATEWAY_RESTART_DELAY}s..."
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;
@@ -91,7 +91,7 @@ async function createConnection() {
91
  method: "connect",
92
  params: {
93
  minProtocol: 3,
94
- maxProtocol: 3,
95
  client: {
96
  id: "gateway-client",
97
  version: "1.0.0",
@@ -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;
 
91
  method: "connect",
92
  params: {
93
  minProtocol: 3,
94
+ maxProtocol: 4,
95
  client: {
96
  id: "gateway-client",
97
  version: "1.0.0",
 
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);