anurag008w commited on
Commit
d65efac
·
1 Parent(s): 0a82210

Merge pull request #31 from anurag162008/codex/find-automatic-package-installation-method-d90k9g

Browse files
Files changed (12) hide show
  1. .env.example +4 -0
  2. .gitattributes +34 -0
  3. .gitignore +4 -7
  4. CHANGELOG.md +13 -0
  5. CONTRIBUTING.md +3 -2
  6. Dockerfile +37 -11
  7. README.md +50 -12
  8. SECURITY.md +1 -0
  9. health-server.js +234 -307
  10. login.html +59 -0
  11. openclaw-sync.py +1 -4
  12. start.sh +131 -13
.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 \
@@ -43,11 +49,17 @@ RUN apt-get update && apt-get install -y \
43
  xfonts-scalable \
44
  --no-install-recommends && \
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 +72,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 +86,30 @@ 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 +121,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 \
 
49
  xfonts-scalable \
50
  --no-install-recommends && \
51
  pip3 install --no-cache-dir --break-system-packages huggingface_hub && \
52
+ case "$(printf '%s' "${DEV_MODE}" | tr '[:upper:]' '[:lower:]')" in \
53
+ true|1|yes|on) \
54
+ pip3 install --no-cache-dir --break-system-packages \
55
+ jupyterlab==4.5.7 \
56
+ tornado==6.5.5 \
57
+ ipywidgets==8.1.8 ;; \
58
+ esac && \
59
  rm -rf /var/lib/apt/lists/*
60
 
61
  # Reuse existing node user (UID 1000). Allow passwordless package-manager
62
+ # commands only so runtime apt installs can be replayed after HF Space restarts.
 
63
  RUN mkdir -p /home/node/app /home/node/.openclaw && \
64
  chown -R 1000:1000 /home/node && \
65
  printf '%s\n' \
 
72
  # Copy pre-built OpenClaw (skips npm install entirely — much faster!)
73
  COPY --from=openclaw --chown=1000:1000 /app /home/node/.openclaw/openclaw-app
74
 
75
+ # Add Playwright in an isolated sidecar node_modules
 
76
  RUN mkdir -p /home/node/browser-deps && \
77
  cd /home/node/browser-deps && \
78
  npm init -y && \
 
86
  COPY --chown=1000:1000 cloudflare-proxy.js /opt/cloudflare-proxy.js
87
  COPY --chown=1000:1000 cloudflare-proxy-setup.py /home/node/app/cloudflare-proxy-setup.py
88
  COPY --chown=1000:1000 health-server.js /home/node/app/health-server.js
89
+ COPY --chown=1000:1000 login.html /home/node/app/login.html
90
  COPY --chown=1000:1000 iframe-fix.cjs /home/node/app/iframe-fix.cjs
91
  COPY --chown=1000:1000 start.sh /home/node/app/start.sh
92
  COPY --chown=1000:1000 wa-guardian.js /home/node/app/wa-guardian.js
93
  COPY --chown=1000:1000 cloudflare-keepalive-setup.py /home/node/app/cloudflare-keepalive-setup.py
94
  COPY --chown=1000:1000 openclaw-sync.py /home/node/app/openclaw-sync.py
 
95
  COPY --chown=1000:1000 multi-provider-key-rotator.cjs /home/node/app/multi-provider-key-rotator.cjs
96
+ RUN printf '%s\n' \
97
+ '#!/usr/bin/env bash' \
98
+ 'set -euo pipefail' \
99
+ 'case "$(printf "%s" "${DEV_MODE}" | tr "[:upper:]" "[:lower:]")" in' \
100
+ ' true|1|yes|on)' \
101
+ " python3 -c \"from pathlib import Path; import shutil, jupyter_server; template_dir = Path(jupyter_server.__file__).parent / 'templates'; template_dir.mkdir(parents=True, exist_ok=True); shutil.copyfile('/home/node/app/login.html', template_dir / 'login.html')\"" \
102
+ ' ;;' \
103
+ 'esac' \
104
+ > /tmp/setup-jupyter-template.sh && \
105
+ chmod +x /tmp/setup-jupyter-template.sh && \
106
+ /tmp/setup-jupyter-template.sh && \
107
+ rm -f /tmp/setup-jupyter-template.sh
108
+ RUN chmod +x /home/node/app/start.sh \
109
+ /home/node/app/cloudflare-proxy-setup.py \
110
+ /home/node/app/cloudflare-keepalive-setup.py \
111
+ /home/node/app/openclaw-sync.py \
112
+ /home/node/app/multi-provider-key-rotator.cjs
113
 
114
  USER node
115
 
 
121
 
122
  WORKDIR /home/node/app
123
 
124
+ # 7861 = public entrypoint (dashboard + proxy for both OpenClaw and JupyterLab)
125
  EXPOSE 7861
126
 
 
 
127
  HEALTHCHECK --interval=30s --timeout=5s --start-period=90s \
128
  CMD curl -fsS http://localhost:7861/health || exit 1
129
 
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
health-server.js CHANGED
@@ -1,4 +1,4 @@
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");
@@ -6,6 +6,13 @@ const net = require("net");
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;
@@ -19,40 +26,26 @@ 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 +53,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 +97,239 @@ 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 fs = require("fs");
4
  const net = require("net");
 
6
  const PORT = 7861;
7
  const GATEWAY_PORT = 7860;
8
  const GATEWAY_HOST = "127.0.0.1";
9
+ const JUPYTER_PORT = 8888;
10
+ const JUPYTER_HOST = "127.0.0.1";
11
+ const JUPYTER_BASE = "/terminal";
12
+ const DEV_MODE_ENABLED = /^(true|1|yes|on)$/i.test(process.env.DEV_MODE || "");
13
+ const JUPYTER_ENABLED = /^(true|1|yes|on)$/i.test(
14
+ process.env.HUGGINGCLAW_JUPYTER_ENABLED || (DEV_MODE_ENABLED ? "true" : "false")
15
+ );
16
  const startTime = Date.now();
17
  const LLM_MODEL = process.env.LLM_MODEL || "Not Set";
18
  const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN;
 
26
  "/tmp/huggingclaw-cloudflare-keepalive-status.json";
27
 
28
  function parseRequestUrl(url) {
29
+ try { return new URL(url, "http://localhost"); }
30
+ catch { return new URL("http://localhost/"); }
 
 
 
31
  }
32
 
33
  function getSyncStatus() {
34
  try {
35
+ if (fs.existsSync(SYNC_STATUS_FILE))
36
  return JSON.parse(fs.readFileSync(SYNC_STATUS_FILE, "utf8"));
 
37
  } catch {}
38
+ if (HF_BACKUP_ENABLED)
39
+ return { status: "configured", message: `Backup enabled. Waiting for sync window (${SYNC_INTERVAL}s).` };
 
 
 
 
40
  return { status: "unknown", message: "No sync data yet" };
41
  }
42
 
43
  function readGuardianStatus() {
44
+ if (!WHATSAPP_ENABLED) return { configured: false, connected: false, pairing: false };
 
 
45
  try {
46
  if (fs.existsSync(WHATSAPP_STATUS_FILE)) {
47
+ const p = JSON.parse(fs.readFileSync(WHATSAPP_STATUS_FILE, "utf8"));
48
+ return { configured: p.configured !== false, connected: p.connected === true, pairing: p.pairing === true };
 
 
 
 
49
  }
50
  } catch {}
51
  return { configured: true, connected: false, pairing: false };
 
53
 
54
  function getKeepaliveStatus() {
55
  try {
56
+ if (fs.existsSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE))
57
+ return JSON.parse(fs.readFileSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE, "utf8"));
 
 
 
58
  } catch {}
59
  return null;
60
  }
61
 
62
+ function probePort(host, port, path, timeoutMs = 1500) {
63
  return new Promise((resolve) => {
64
+ const req = http.get({ hostname: host, port, path, timeout: timeoutMs }, (res) => {
65
+ res.resume();
66
+ resolve(res.statusCode >= 200 && res.statusCode < 500);
 
 
 
 
 
 
 
 
 
 
 
 
67
  });
68
+ req.on("timeout", () => { req.destroy(); resolve(false); });
69
+ req.on("error", () => resolve(false));
70
  });
71
  }
72
 
73
  function formatUptime(ms) {
74
+ const t = Math.floor(ms / 1000);
75
+ const d = Math.floor(t / 86400), h = Math.floor((t % 86400) / 3600), m = Math.floor((t % 3600) / 60);
76
+ if (d) return `${d}d ${h}h ${m}m`;
77
+ if (h) return `${h}h ${m}m`;
78
+ return `${m}m`;
 
 
79
  }
80
 
81
+ function escapeHtml(v) {
82
+ return String(v).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
 
 
 
 
83
  }
84
 
85
+ function badge(label, tone = "neutral") {
86
  return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
87
  }
88
 
89
+ function tile({ title, value, detail = "", tone = "neutral", meta = "" }) {
 
 
 
 
 
 
90
  return `<article class="tile ${tone}">
91
+ <div class="tile-head"><span class="tile-title">${escapeHtml(title)}</span><span class="tile-dot"></span></div>
 
 
 
92
  <div class="tile-value">${value}</div>
93
  ${detail ? `<div class="tile-detail">${detail}</div>` : ""}
94
  ${meta ? `<div class="tile-meta">${meta}</div>` : ""}
 
97
 
98
  function renderDashboard(data) {
99
  const syncStatus = String(data.sync?.status || "unknown");
100
+ const syncTone = ["success","restored","synced","configured"].includes(syncStatus) ? "ok" : syncStatus === "disabled" ? "warn" : "neutral";
101
+ const kaConf = data.keepalive?.configured === true;
102
+ const kaStatus = String(data.keepalive?.status || (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"));
103
+ const kaTone = kaConf ? "ok" : process.env.CLOUDFLARE_WORKERS_TOKEN ? "warn" : "neutral";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  const tiles = [
106
+ 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" }),
107
+ tile({ title: "Model", value: `<code>${escapeHtml(LLM_MODEL)}</code>`, detail: "Primary LLM configured", tone: "neutral" }),
108
+ tile({ title: "Runtime", value: escapeHtml(data.uptimeHuman), detail: `Public port ${PORT}`, tone: "neutral" }),
109
+ 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" }),
110
+ 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>` : "" }),
111
+ 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 }),
112
+ ];
113
+
114
+ if (JUPYTER_ENABLED) {
115
+ tiles.push(tile({ title: "Terminal", value: badge(data.jupyterReady ? "Online" : "Starting…", data.jupyterReady ? "ok" : "warn"), detail: `JupyterLab at <a href="${JUPYTER_BASE}/" style="color:inherit">${JUPYTER_BASE}/</a>`, tone: data.jupyterReady ? "ok" : "warn" }));
116
+ }
117
+
118
+ const tilesHtml = tiles.join("");
119
+
120
+ return `<!doctype html><html lang="en"><head>
121
+ <meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  <title>HuggingClaw</title>
123
  <style>
124
+ :root{color-scheme:dark;--bg:#08080f;--panel:#12111b;--line:#26243a;--text:#f6f4ff;--muted:#7f7a9e;--soft:#b8b3d7;--good:#22c55e;--warn:#f5c542;--bad:#fb7185}
125
+ *{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}
126
+ main{width:min(720px,calc(100% - 32px));margin:0 auto;padding:36px 0 44px}
127
+ header{text-align:center;margin-bottom:22px}h1{margin:0;font-size:1.65rem;line-height:1}
128
+ .subtitle{margin-top:12px;color:var(--muted);font-size:.72rem;text-transform:uppercase;letter-spacing:.14em;font-weight:800}
129
+ .btn-row{display:flex;gap:12px;margin:24px 0 20px}
130
+ .hero-action{display:flex;flex:1;min-height:46px;align-items:center;justify-content:center;border-radius:8px;background:#fff;color:#000;text-decoration:none;font-weight:850;font-size:.98rem;transition:opacity .15s}
131
+ .hero-action:hover{opacity:.9}.hero-action.terminal{background:#1e1e2e;color:#cdd6f4;border:1px solid #45475a}
132
+ .overview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:10px}
133
+ .tile{border:1px solid var(--line);background:var(--panel);border-radius:11px;padding:18px;min-height:124px;display:flex;flex-direction:column;gap:10px}
134
+ .tile.ok{border-color:rgba(34,197,94,.22)}.tile.warn{border-color:rgba(245,197,66,.24)}.tile.off{border-color:rgba(251,113,133,.28)}
135
+ .tile-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
136
+ .tile-title{color:var(--muted);font-size:.67rem;letter-spacing:.18em;text-transform:uppercase;font-weight:850}
137
+ .tile-dot{width:7px;height:7px;border-radius:50%;background:var(--line)}
138
+ .tile.ok .tile-dot{background:var(--good)}.tile.warn .tile-dot{background:var(--warn)}.tile.off .tile-dot{background:var(--bad)}
139
+ .tile-value{font-size:1.12rem;font-weight:850;overflow-wrap:anywhere}.tile-detail{color:var(--soft);line-height:1.45;font-size:.83rem}
140
+ .tile-meta{color:var(--muted);line-height:1.4;font-size:.75rem;margin-top:auto;overflow-wrap:anywhere}
141
+ code{background:#232234;border:1px solid #34324c;border-radius:6px;padding:2px 6px;color:var(--text);font-size:.9em}
142
+ .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}
143
+ .badge.ok{color:var(--good);border-color:rgba(34,197,94,.34);background:rgba(34,197,94,.11)}
144
+ .badge.warn{color:var(--warn);border-color:rgba(245,197,66,.34);background:rgba(245,197,66,.11)}
145
+ .badge.off{color:var(--bad);border-color:rgba(251,113,133,.34);background:rgba(251,113,133,.11)}
146
+ .badge.neutral{color:var(--soft)}
147
+ footer{color:var(--muted);text-align:center;font-size:.74rem;margin-top:18px}
148
+ @media(max-width:700px){.overview{grid-template-columns:1fr}main{width:min(100% - 22px,720px);padding-top:28px}.btn-row{flex-direction:column}}
149
+ </style></head><body><main>
150
+ <header><h1>🦞 HuggingClaw</h1><div class="subtitle">OpenClaw Gateway</div></header>
151
+ <div class="btn-row">
152
+ <a class="hero-action" href="${APP_BASE}/">Open Control UI →</a>
153
+ ${JUPYTER_ENABLED ? `<a class="hero-action terminal" href="${JUPYTER_BASE}/">💻 Open Terminal →</a>` : ""}
154
+ </div>
155
+ <section class="overview">${tilesHtml}</section>
156
+ <footer>Built by <a href="https://github.com/somratpro" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:none">@somratpro</a>${JUPYTER_ENABLED ? " · Terminal by JupyterLab" : ""}<br><span>Public Spaces can be opened directly via <code>.hf.space</code>; private Spaces require the App tab session.</span></footer>
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  </main>
158
+ <script>document.querySelectorAll('.local-time').forEach(el=>{const d=new Date(el.getAttribute('data-iso'));if(!isNaN(d))el.textContent='At '+d.toLocaleTimeString()});</script>
159
+ </body></html>`;
160
+ }
161
+
162
+ // ── Generic proxy ──
163
+ function proxiedPath(url, { stripPrefix = "" } = {}) {
164
+ if (!stripPrefix) return url.pathname + url.search;
165
+ if (url.pathname === stripPrefix) return "/" + url.search;
166
+ if (url.pathname.startsWith(stripPrefix + "/")) {
167
+ return url.pathname.slice(stripPrefix.length) + url.search;
168
+ }
169
+ return url.pathname + url.search;
170
+ }
171
+
172
+ function rewriteProxyHeaders(headers, { publicPrefix = "", targetHost = "", targetPort = "" } = {}) {
173
+ const next = { ...headers };
174
+
175
+ // Keep browser redirects inside the public HF Space path. Backends may emit
176
+ // root-relative redirects ("/login") or absolute redirects pointing at their
177
+ // internal listener ("http://127.0.0.1:8888/..."). Both break from a browser
178
+ // if we do not normalize them back to the public mount path.
179
+ if (publicPrefix && typeof next.location === "string") {
180
+ try {
181
+ const internalOrigins = new Set([
182
+ "http://huggingclaw.local",
183
+ `http://${targetHost}:${targetPort}`,
184
+ `http://localhost:${targetPort}`,
185
+ `http://127.0.0.1:${targetPort}`,
186
+ ]);
187
+ const location = new URL(next.location, "http://huggingclaw.local");
188
+ if (internalOrigins.has(location.origin)) {
189
+ let path = location.pathname;
190
+ if (path !== publicPrefix && !path.startsWith(publicPrefix + "/")) {
191
+ path = publicPrefix + (path.startsWith("/") ? path : `/${path}`);
192
+ }
193
+ next.location = path + location.search + location.hash;
194
+ }
195
+ } catch {}
196
+ }
197
+
198
+ return next;
199
+ }
200
+
201
+ function sendServiceUnavailable(res) {
202
+ if (!res.headersSent) {
203
+ res.writeHead(503, { "Content-Type": "application/json" });
204
+ res.end(JSON.stringify({ status: "starting", message: "Service is initializing… please wait." }));
205
+ } else {
206
+ res.end();
207
+ }
208
+ }
209
+
210
+ function proxyHTTP(req, res, targetHost, targetPort, options = {}) {
211
+ const url = parseRequestUrl(req.url);
212
+ const headers = {
213
+ ...req.headers,
214
+ host: `${targetHost}:${targetPort}`,
215
+ "x-forwarded-for": req.socket.remoteAddress,
216
+ "x-forwarded-host": req.headers.host,
217
+ "x-forwarded-proto": "https",
218
+ "x-forwarded-prefix": options.publicPrefix || "",
219
+ };
220
+
221
+ const canReplayRequest = req.method === "GET" || req.method === "HEAD";
222
+ const proxyOnce = (path, retryOn404) => {
223
+ const pr = http.request({ hostname: targetHost, port: targetPort, path, method: req.method, headers }, (pres) => {
224
+ if (canReplayRequest && retryOn404 && pres.statusCode === 404 && options.stripPrefix) {
225
+ pres.resume();
226
+ return proxyOnce(proxiedPath(url, { stripPrefix: options.stripPrefix }), false);
227
  }
228
+ res.writeHead(pres.statusCode, rewriteProxyHeaders(pres.headers, { ...options, targetHost, targetPort }));
229
+ pres.pipe(res);
230
+ pres.on("error", () => res.end());
231
  });
232
+ req.on("error", () => pr.destroy());
233
+ res.on("error", () => pr.destroy());
234
+ pr.on("error", () => sendServiceUnavailable(res));
235
+ req.pipe(pr);
236
+ };
237
+
238
+ // First try the public path as-is because OpenClaw and JupyterLab are both
239
+ // configured with base paths. If a backend still returns 404, retry with the
240
+ // mount prefix stripped; that covers images built before the base-path config
241
+ // took effect and avoids the common HF Spaces "404 at /app or /terminal" trap.
242
+ proxyOnce(url.pathname + url.search, !!options.retryWithoutPrefixOn404);
243
  }
244
 
245
+ // ── HTTP server ──
246
  const server = http.createServer(async (req, res) => {
247
+ const { pathname } = parseRequestUrl(req.url);
 
248
 
 
249
  if (pathname === "/health") {
250
+ const gatewayReady = await probePort(GATEWAY_HOST, GATEWAY_PORT, "/health");
251
+ res.writeHead(gatewayReady ? 200 : 503, { "Content-Type": "application/json" });
252
+ return res.end(JSON.stringify({ status: gatewayReady ? "ok" : "degraded", gatewayReady, uptime: formatUptime(Date.now() - startTime), sync: getSyncStatus(), keepalive: getKeepaliveStatus() }));
 
 
 
 
 
 
 
 
 
 
253
  }
254
 
255
  if (pathname === "/status") {
256
+ const [gatewayReady, jupyterReady] = await Promise.all([
257
+ probePort(GATEWAY_HOST, GATEWAY_PORT, "/health"),
258
+ JUPYTER_ENABLED ? probePort(JUPYTER_HOST, JUPYTER_PORT, `${JUPYTER_BASE}/api`) : Promise.resolve(false),
259
+ ]);
260
  res.writeHead(200, { "Content-Type": "application/json" });
261
+ return res.end(JSON.stringify({ model: LLM_MODEL, uptime: formatUptime(Date.now() - startTime), gatewayReady, jupyterReady, sync: getSyncStatus(), whatsapp: readGuardianStatus(), keepalive: getKeepaliveStatus() }));
 
 
 
 
 
 
 
 
 
262
  }
263
 
264
  if (pathname === "/" || pathname === "/dashboard") {
265
+ const [gatewayReady, jupyterReady] = await Promise.all([
266
+ probePort(GATEWAY_HOST, GATEWAY_PORT, "/health"),
267
+ JUPYTER_ENABLED ? probePort(JUPYTER_HOST, JUPYTER_PORT, `${JUPYTER_BASE}/api`) : Promise.resolve(false),
268
+ ]);
269
  res.writeHead(200, { "Content-Type": "text/html" });
270
+ return res.end(renderDashboard({ uptimeHuman: formatUptime(Date.now() - startTime), gatewayReady, jupyterReady, sync: getSyncStatus(), whatsapp: readGuardianStatus(), keepalive: getKeepaliveStatus() }));
 
 
 
 
 
 
 
 
271
  }
272
 
273
+ // JupyterLab terminal
274
+ if (pathname === JUPYTER_BASE || pathname.startsWith(JUPYTER_BASE + "/")) {
275
+ if (!JUPYTER_ENABLED) {
276
+ res.writeHead(404, { "Content-Type": "application/json" });
277
+ return res.end(JSON.stringify({ status: "disabled", message: "JupyterLab terminal is disabled. Set DEV_MODE=true to enable /terminal/." }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  }
279
+ return proxyHTTP(req, res, JUPYTER_HOST, JUPYTER_PORT, {
280
+ publicPrefix: JUPYTER_BASE,
281
+ // Jupyter is started with --ServerApp.base_url=/terminal/, so keep the
282
+ // /terminal prefix when proxying. Stripping it breaks static/theme URLs.
283
+ stripPrefix: "",
284
+ retryWithoutPrefixOn404: false,
285
+ });
286
+ }
287
+
288
+ // OpenClaw Control UI mounted under /app. Retry without the mount prefix on
289
+ // 404 so deployments keep working across OpenClaw basePath behavior changes.
290
+ if (pathname === APP_BASE || pathname.startsWith(APP_BASE + "/")) {
291
+ return proxyHTTP(req, res, GATEWAY_HOST, GATEWAY_PORT, {
292
+ publicPrefix: APP_BASE,
293
+ stripPrefix: APP_BASE,
294
+ retryWithoutPrefixOn404: true,
295
+ });
296
+ }
297
 
298
+ // OpenClaw gateway API/static fallback (everything else)
299
+ proxyHTTP(req, res, GATEWAY_HOST, GATEWAY_PORT);
300
  });
301
 
302
+ // ── WebSocket upgrade (JupyterLab kernels + terminals need this) ──
303
  server.on("upgrade", (req, socket, head) => {
304
+ const { pathname, search } = parseRequestUrl(req.url);
305
+ const isJupyter = JUPYTER_ENABLED && (pathname === JUPYTER_BASE || pathname.startsWith(JUPYTER_BASE + "/"));
306
+ const isApp = pathname === APP_BASE || pathname.startsWith(APP_BASE + "/");
307
+ const [targetHost, targetPort] = isJupyter ? [JUPYTER_HOST, JUPYTER_PORT] : [GATEWAY_HOST, GATEWAY_PORT];
308
+ const publicPrefix = isJupyter ? JUPYTER_BASE : isApp ? APP_BASE : "";
309
+ const targetPath = pathname + search;
310
+
311
+ const ps = net.connect(targetPort, targetHost, () => {
312
+ ps.write(`${req.method} ${targetPath} HTTP/${req.httpVersion}\r\n`);
313
+ ps.write(`Host: ${targetHost}:${targetPort}\r\n`);
314
+ ps.write(`X-Forwarded-For: ${req.socket.remoteAddress || ""}\r\n`);
315
+ ps.write(`X-Forwarded-Host: ${req.headers.host || ""}\r\n`);
316
+ ps.write("X-Forwarded-Proto: https\r\n");
317
+ if (publicPrefix) ps.write(`X-Forwarded-Prefix: ${publicPrefix}\r\n`);
318
  for (let i = 0; i < req.rawHeaders.length; i += 2) {
319
+ const header = req.rawHeaders[i];
320
+ const lower = header.toLowerCase();
321
+ if (["host", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "x-forwarded-prefix"].includes(lower)) continue;
322
+ ps.write(`${header}: ${req.rawHeaders[i + 1]}\r\n`);
323
  }
324
+ ps.write("\r\n");
325
+ if (head && head.length) ps.write(head);
326
+ ps.pipe(socket).pipe(ps);
327
  });
328
+ ps.on("error", () => socket.destroy());
329
  });
330
 
331
  server.timeout = 0;
332
  server.keepAliveTimeout = 65000;
333
  server.listen(PORT, "0.0.0.0", () =>
334
+ console.log(`🦞 HuggingClaw :${PORT} → Gateway :${GATEWAY_PORT}${JUPYTER_ENABLED ? ` | Terminal :${JUPYTER_PORT} at ${JUPYTER_BASE}/` : " | Terminal disabled"}`),
 
 
335
  );
login.html ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "page.html" %}
2
+
3
+ {% block stylesheet %}
4
+ {% endblock %}
5
+
6
+ {% block site %}
7
+ <div id="jupyter-main-app" class="container" style="text-align:center; max-width: 760px; margin-top: 40px;">
8
+ <img src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg" alt="Hugging Face Logo" style="max-width: 120px; margin-bottom: 24px;">
9
+ <h3>HuggingClaw Terminal</h3>
10
+ <h4>Welcome to JupyterLab</h4>
11
+ <h5>The default token is <span style="color:orange;">huggingface</span></h5>
12
+ <p style="color:#666;">This terminal is mounted at <code>/terminal/</code> inside the same Hugging Face Space as the OpenClaw UI.</p>
13
+
14
+ {% if login_available %}
15
+ <div class="row" style="display:flex; justify-content:center; margin-top:24px;">
16
+ <div class="navbar col-sm-8">
17
+ <div class="navbar-inner">
18
+ <div class="container">
19
+ <div class="center-nav">
20
+ <form action="{{base_url}}login?next={{next}}" method="post" class="navbar-form pull-left">
21
+ {{ xsrf_form_html() | safe }}
22
+ {% if token_available %}
23
+ <label for="password_input"><strong>{% trans %}Jupyter token <span title="This is the secret you set up when deploying your JupyterLab terminal">ⓘ</span> {% endtrans %}</strong></label>
24
+ {% else %}
25
+ <label for="password_input"><strong>{% trans %}Jupyter password:{% endtrans %}</strong></label>
26
+ {% endif %}
27
+ <input type="password" name="password" id="password_input" class="form-control">
28
+ <button type="submit" class="btn btn-default" id="login_submit">{% trans %}Log in{% endtrans %}</button>
29
+ </form>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ {% else %}
36
+ <p>{% trans %}No login available, you shouldn't be seeing this page.{% endtrans %}</p>
37
+ {% endif %}
38
+
39
+ <h5 style="margin-top:28px;"><a href="/dashboard">Back to HuggingClaw dashboard</a></h5>
40
+ <p>This login page is based on the Hugging Face JupyterLab Space template.</p>
41
+
42
+ {% if message %}
43
+ <div class="row">
44
+ {% for key in message %}
45
+ <div class="message {{key}}">
46
+ {{message[key]}}
47
+ </div>
48
+ {% endfor %}
49
+ </div>
50
+ {% endif %}
51
+ {% if token_available %}
52
+ {% block token_message %}
53
+ {% endblock token_message %}
54
+ {% endif %}
55
+ </div>
56
+ {% endblock %}
57
+
58
+ {% block script %}
59
+ {% endblock %}
openclaw-sync.py CHANGED
@@ -483,10 +483,7 @@ def _sync_once_unlocked(
483
  commit_message=f"HuggingClaw sync {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
484
  ignore_patterns=[".git/*", ".git"],
485
  )
486
- try:
487
- prune_remote_deleted_files(repo_id, snapshot_dir)
488
- except Exception as prune_exc:
489
- print(f"Warning: could not prune stale remote files: {prune_exc}")
490
  finally:
491
  shutil.rmtree(snapshot_dir, ignore_errors=True)
492
 
 
483
  commit_message=f"HuggingClaw sync {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
484
  ignore_patterns=[".git/*", ".git"],
485
  )
486
+ prune_remote_deleted_files(repo_id, snapshot_dir)
 
 
 
487
  finally:
488
  shutil.rmtree(snapshot_dir, ignore_errors=True)
489
 
start.sh CHANGED
@@ -21,6 +21,13 @@ 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}"
@@ -39,7 +46,7 @@ else
39
  fi
40
  echo ""
41
  echo " ╔══════════════════════════════════════════╗"
42
- echo " ║ 🦞 HuggingClaw Gateway ║"
43
  echo " ╚══════════════════════════════════════════╝"
44
  echo ""
45
 
@@ -433,6 +440,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 +461,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")
@@ -649,8 +655,29 @@ fi
649
  if [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
650
  echo "Proxy : ${CLOUDFLARE_PROXY_URL}"
651
  fi
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  if [ -n "${SPACE_HOST:-}" ]; then
653
- echo "Control UI: https://${SPACE_HOST}/app"
 
 
 
 
 
654
  fi
655
  echo ""
656
 
@@ -709,6 +736,47 @@ export LLM_MODEL="$LLM_MODEL"
709
  node /home/node/app/health-server.js &
710
  HEALTH_PID=$!
711
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
712
  if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then
713
  echo "Setting up Cloudflare KeepAlive monitor..."
714
  python3 /home/node/app/cloudflare-keepalive-setup.py || true
@@ -730,6 +798,9 @@ 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 +827,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 +900,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 +927,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
  ;;
@@ -861,7 +954,7 @@ pip() {
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
@@ -873,15 +966,39 @@ pip3() {
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,7 +1006,7 @@ 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
@@ -935,7 +1052,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 +1076,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"
 
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
+ DEV_MODE_RAW="${DEV_MODE:-false}"
25
+ DEV_MODE_NORMALIZED=$(printf '%s' "$DEV_MODE_RAW" | tr '[:upper:]' '[:lower:]')
26
+ DEV_MODE_ENABLED=false
27
+ case "$DEV_MODE_NORMALIZED" in
28
+ true|1|yes|on) DEV_MODE_ENABLED=true ;;
29
+ *) DEV_MODE_ENABLED=false ;;
30
+ esac
31
  SYNC_INTERVAL="${SYNC_INTERVAL:-180}"
32
  if [ -n "${SPACE_HOST:-}" ]; then
33
  OPENCLAW_CONSOLE_LOG_LEVEL="${OPENCLAW_CONSOLE_LOG_LEVEL:-warn}"
 
46
  fi
47
  echo ""
48
  echo " ╔══════════════════════════════════════════╗"
49
+ echo " ║ 🦞 HuggingClaw + 💻 JupyterLab ║"
50
  echo " ╚══════════════════════════════════════════╝"
51
  echo ""
52
 
 
440
  # plugins that the Control UI/dashboard needs to render correctly
441
  # on HF Spaces. Without these the UI shows blank panels.
442
  # telegram/whatsapp/browser/acpx are added conditionally below.
443
+ # Do not create a disabled acpx entry when the plugin is absent;
444
+ # OpenClaw reports that as a config warning on HF Spaces.
445
  # DENY: lmstudio crashes on boot when no local server is reachable;
446
  # xai PLUGIN (separate from the xai model PROVIDER) is broken in
447
  # current OpenClaw releases and prevents gateway start. Disabling
 
461
  fi
462
 
463
  # Apply plugin allow/deny + per-entry toggles in one jq pass.
 
 
464
  BROWSER_DISABLED=true
465
  if [ "$BROWSER_SHOULD_ENABLE" = "true" ]; then BROWSER_DISABLED=false; fi
466
 
467
  CONFIG_JSON=$(jq \
468
  --argjson allow "$PLUGIN_ALLOW_JSON" \
 
469
  --argjson browserDisabled "$BROWSER_DISABLED" \
470
  '.plugins.allow = $allow
471
  | .plugins.deny = ["lmstudio","xai"]
472
  | .plugins.entries.lmstudio.enabled = false
473
  | .plugins.entries.xai.enabled = false
474
+ | del(.plugins.entries.acpx)
475
  | (if $browserDisabled then
476
  .plugins.entries.browser.enabled = false | .browser.enabled = false
477
  else . end)' <<<"$CONFIG_JSON")
 
655
  if [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
656
  echo "Proxy : ${CLOUDFLARE_PROXY_URL}"
657
  fi
658
+ RUNTIME_JUPYTER_ENABLED="$DEV_MODE_ENABLED"
659
+ if [ "$DEV_MODE_ENABLED" = "true" ] && ! command -v jupyter-lab >/dev/null 2>&1; then
660
+ echo "DEV_MODE enabled but jupyter-lab is missing; attempting runtime install..."
661
+ if python3 -m pip install --user --no-cache-dir --break-system-packages jupyterlab==4.5.7 tornado==6.5.5 ipywidgets==8.1.8; then
662
+ echo "Runtime Jupyter install complete."
663
+ else
664
+ echo "WARNING: Runtime Jupyter install failed; disabling terminal for this boot."
665
+ RUNTIME_JUPYTER_ENABLED=false
666
+ fi
667
+ fi
668
+ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ] && ! command -v jupyter-lab >/dev/null 2>&1; then
669
+ echo "WARNING: jupyter-lab still unavailable; disabling terminal for this boot."
670
+ RUNTIME_JUPYTER_ENABLED=false
671
+ fi
672
+ export HUGGINGCLAW_JUPYTER_ENABLED="$RUNTIME_JUPYTER_ENABLED"
673
+
674
  if [ -n "${SPACE_HOST:-}" ]; then
675
+ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ]; then
676
+ echo "Routes : /app/ (Control UI), /terminal/ (JupyterLab)"
677
+ else
678
+ echo "Routes : /app/ (Control UI)"
679
+ fi
680
+ echo "Private : open the Hugging Face App tab first; raw https://${SPACE_HOST}/... links can show HF 404 without the embedded Space session."
681
  fi
682
  echo ""
683
 
 
736
  node /home/node/app/health-server.js &
737
  HEALTH_PID=$!
738
 
739
+ # 10.5. Start JupyterLab Terminal on internal port 8888 (DEV_MODE only)
740
+ # Accessible via /terminal/ path through the health-server proxy
741
+ if [ "$RUNTIME_JUPYTER_ENABLED" = "true" ]; then
742
+ JUPYTER_TOKEN="${JUPYTER_TOKEN:-huggingface}"
743
+ JUPYTER_ROOT_DIR="${JUPYTER_ROOT_DIR:-/home/node}"
744
+ mkdir -p "$JUPYTER_ROOT_DIR"
745
+ if [ "$JUPYTER_ROOT_DIR" != "/home/node/app" ]; then
746
+ if [ -L "$JUPYTER_ROOT_DIR/HuggingClaw" ] || [ ! -e "$JUPYTER_ROOT_DIR/HuggingClaw" ]; then
747
+ ln -sfn /home/node/app "$JUPYTER_ROOT_DIR/HuggingClaw"
748
+ fi
749
+ fi
750
+ if [ "$JUPYTER_ROOT_DIR" != "/home/node/.openclaw/workspace" ]; then
751
+ if [ -L "$JUPYTER_ROOT_DIR/HuggingClaw-Workspace" ] || [ ! -e "$JUPYTER_ROOT_DIR/HuggingClaw-Workspace" ]; then
752
+ ln -sfn /home/node/.openclaw/workspace "$JUPYTER_ROOT_DIR/HuggingClaw-Workspace"
753
+ fi
754
+ fi
755
+
756
+ echo "DEV_MODE enabled (${DEV_MODE_RAW}) — starting JupyterLab terminal on internal port 8888 (path: /terminal/) with root: $JUPYTER_ROOT_DIR"
757
+ jupyter-lab \
758
+ --ip 127.0.0.1 \
759
+ --port 8888 \
760
+ --no-browser \
761
+ --IdentityProvider.token="$JUPYTER_TOKEN" \
762
+ --ServerApp.base_url=/terminal/ \
763
+ --ServerApp.terminals_enabled=True \
764
+ --ServerApp.allow_origin='*' \
765
+ --ServerApp.allow_remote_access=True \
766
+ --ServerApp.trust_xheaders=True \
767
+ --ServerApp.tornado_settings="{'headers': {'Content-Security-Policy': 'frame-ancestors *'}}" \
768
+ --IdentityProvider.cookie_options="{'SameSite': 'None', 'Secure': True}" \
769
+ --ServerApp.disable_check_xsrf=True \
770
+ --LabApp.news_url=None \
771
+ --LabApp.check_for_updates_class="jupyterlab.NeverCheckForUpdate" \
772
+ --notebook-dir="$JUPYTER_ROOT_DIR" \
773
+ 2>&1 | tee -a /tmp/jupyterlab.log &
774
+ JUPYTER_PID=$!
775
+ echo "JupyterLab started (PID: $JUPYTER_PID)"
776
+ else
777
+ echo "Jupyter terminal disabled for this boot (DEV_MODE=${DEV_MODE_RAW})."
778
+ fi
779
+
780
  if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then
781
  echo "Setting up Cloudflare KeepAlive monitor..."
782
  python3 /home/node/app/cloudflare-keepalive-setup.py || true
 
798
  export DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"
799
  STARTUP_FILE="/home/node/.openclaw/workspace/startup.sh"
800
  _hc_append() {
801
+ if [ "${HUGGINGCLAW_CAPTURE_DISABLE:-0}" = "1" ]; then
802
+ return 0
803
+ fi
804
  local line="$*"
805
  mkdir -p "$(dirname "$STARTUP_FILE")"
806
  touch "$STARTUP_FILE"
 
827
  _hc_append "$cmd"
828
  fi
829
  }
830
+ _hc_args_without_flags() {
831
+ local out=()
832
+ local arg
833
+ for arg in "$@"; do
834
+ case "$arg" in
835
+ ''|-) ;;
836
+ --*) ;;
837
+ -*) ;;
838
+ *) out+=("$arg") ;;
839
+ esac
840
+ done
841
+ printf '%s\n' "${out[@]}"
842
+ }
843
+ _hc_has_install_targets() {
844
+ local item
845
+ while IFS= read -r item; do
846
+ [ -n "$item" ] && return 0
847
+ done <<EOF
848
+ $(_hc_args_without_flags "$@")
849
+ EOF
850
+ return 1
851
+ }
852
  _hc_allow_openclaw_plugins() {
853
  local config="/home/node/.openclaw/openclaw.json"
854
  [ -f "$config" ] || return 0
 
900
  _hc_apt_install "$@"
901
  local rc=$?
902
  if [ $rc -eq 0 ]; then
903
+ _hc_has_install_targets "$@" && _hc_append_cmd "sudo apt-get update && sudo apt-get install -y" "$@"
904
  fi
905
  return $rc
906
  ;;
 
927
  _hc_apt_install "$@"
928
  local rc=$?
929
  if [ $rc -eq 0 ]; then
930
+ _hc_has_install_targets "$@" && _hc_append_cmd "sudo apt-get update && sudo apt-get install -y" "$@"
931
  fi
932
  return $rc
933
  ;;
 
954
  command pip "$@"
955
  fi
956
  local rc=$?
957
+ if [ $rc -eq 0 ] && [ "${1:-}" = "install" ] && _hc_has_install_targets "${@:2}"; then
958
  _hc_append_cmd "python3 -m pip install --user" "${@:2}"
959
  fi
960
  return $rc
 
966
  command pip3 "$@"
967
  fi
968
  local rc=$?
969
+ if [ $rc -eq 0 ] && [ "${1:-}" = "install" ] && _hc_has_install_targets "${@:2}"; then
970
  _hc_append_cmd "python3 -m pip install --user" "${@:2}"
971
  fi
972
  return $rc
973
  }
974
+ python() {
975
+ if [ "${1:-}" = "-m" ] && [ "${2:-}" = "pip" ] && [ "${3:-}" = "install" ] && [ -z "${VIRTUAL_ENV:-}" ] && ! _hc_has_arg --user "${@:3}" && ! _hc_has_arg --prefix "${@:3}"; then
976
+ command python -m pip install --user "${@:4}"
977
+ else
978
+ command python "$@"
979
+ fi
980
+ local rc=$?
981
+ if [ $rc -eq 0 ] && [ "${1:-}" = "-m" ] && [ "${2:-}" = "pip" ] && [ "${3:-}" = "install" ] && _hc_has_install_targets "${@:4}"; then
982
+ _hc_append_cmd "python3 -m pip install --user" "${@:4}"
983
+ fi
984
+ return $rc
985
+ }
986
+ python3() {
987
+ if [ "${1:-}" = "-m" ] && [ "${2:-}" = "pip" ] && [ "${3:-}" = "install" ] && [ -z "${VIRTUAL_ENV:-}" ] && ! _hc_has_arg --user "${@:3}" && ! _hc_has_arg --prefix "${@:3}"; then
988
+ command python3 -m pip install --user "${@:4}"
989
+ else
990
+ command python3 "$@"
991
+ fi
992
+ local rc=$?
993
+ if [ $rc -eq 0 ] && [ "${1:-}" = "-m" ] && [ "${2:-}" = "pip" ] && [ "${3:-}" = "install" ] && _hc_has_install_targets "${@:4}"; then
994
+ _hc_append_cmd "python3 -m pip install --user" "${@:4}"
995
+ fi
996
+ return $rc
997
+ }
998
  npm() {
999
  command npm "$@"
1000
  local rc=$?
1001
+ if [ $rc -eq 0 ] && { [ "${1:-}" = "install" ] || [ "${1:-}" = "i" ]; } && { [ "${2:-}" = "-g" ] || [ "${2:-}" = "--global" ]; } && _hc_has_install_targets "${@:3}"; then
1002
  _hc_append_cmd "npm install -g" "${@:3}"
1003
  fi
1004
  return $rc
 
1006
  openclaw() {
1007
  command openclaw "$@"
1008
  local rc=$?
1009
+ if [ $rc -eq 0 ] && [ "${1:-}" = "plugins" ] && [ "${2:-}" = "install" ] && _hc_has_install_targets "${@:3}"; then
1010
  _hc_allow_openclaw_plugins "${@:3}"
1011
  _hc_append_cmd "openclaw plugins install" "${@:3}"
1012
  fi
 
1052
 
1053
  echo "[startup:${source_label}] $command_text"
1054
  set +e
1055
+ HUGGINGCLAW_CAPTURE_DISABLE=1 bash -lc "$command_text"
1056
  local rc=$?
1057
  set -e
1058
  if [ "$rc" -eq 0 ]; then
 
1076
  # Load HuggingClaw's install wrappers for env-provided scripts too, so
1077
  # `apt install`, `pip install`, `npm install -g`, and OpenClaw plugin
1078
  # installs behave the same way as they do in the interactive shell.
1079
+ echo 'export HUGGINGCLAW_CAPTURE_DISABLE=1'
1080
  echo '[ -f /home/node/.bashrc ] && . /home/node/.bashrc'
1081
  printf '%s\n' "$script_text"
1082
  } > "$script_file"