somratpro Claude Sonnet 4.6 commited on
Commit
43301b6
Β·
1 Parent(s): d32fed3

feat: self-healing gateway, package replay, private space guard, key rotation, readme sync

Browse files

- start.sh: gateway restart loop, shell capture wrappers (apt/pip/uv/npm/hermes),
pool key promotion for 16 providers, HUGGINGMES_RUN startup scripts,
env-driven package installs, JupyterLab PID guard, enhanced boot banner
- health-server.js: private space guard (HF API detect + redirect),
embed-aware dashboard buttons, /hf-redirect route
- .env.example: add HUGGINGMES_RUN, package installs, key rotation pools,
gateway restart vars, terminal vars, webhook URL
- README.md: document all new features, fix terminal section (on by default),
add ephemeral package replay and key rotation sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (4) hide show
  1. .env.example +54 -4
  2. README.md +65 -6
  3. health-server.js +132 -3
  4. start.sh +419 -38
.env.example CHANGED
@@ -21,6 +21,12 @@ GATEWAY_TOKEN=your_gateway_token_here
21
  # Allowed Telegram User IDs (comma-separated numeric IDs)
22
  # TELEGRAM_ALLOWED_USERS=123456789,987654321
23
 
 
 
 
 
 
 
24
  # ── OPTIONAL: Cloudflare Proxy & Keep-Alive ──
25
  # Cloudflare API token for automatic Worker proxy and KeepAlive setup
26
  # CLOUDFLARE_WORKERS_TOKEN=your_cloudflare_token_here
@@ -42,9 +48,53 @@ HF_TOKEN=hf_your_token_here
42
  # Backup interval in seconds (default: 180)
43
  SYNC_INTERVAL=180
44
 
45
- # ── OPTIONAL: Advanced ──
46
- # Telegram mode (webhook/polling)
47
- TELEGRAM_MODE=webhook
48
-
49
  # Include .env in backups (default: false)
50
  # SYNC_INCLUDE_ENV=false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  # Allowed Telegram User IDs (comma-separated numeric IDs)
22
  # TELEGRAM_ALLOWED_USERS=123456789,987654321
23
 
24
+ # Telegram mode: "webhook" (default, requires Cloudflare proxy) or "polling"
25
+ # TELEGRAM_MODE=webhook
26
+
27
+ # Telegram webhook URL (auto-set via Cloudflare Worker; override only if manual)
28
+ # TELEGRAM_WEBHOOK_URL=https://your-webhook.workers.dev
29
+
30
  # ── OPTIONAL: Cloudflare Proxy & Keep-Alive ──
31
  # Cloudflare API token for automatic Worker proxy and KeepAlive setup
32
  # CLOUDFLARE_WORKERS_TOKEN=your_cloudflare_token_here
 
48
  # Backup interval in seconds (default: 180)
49
  SYNC_INTERVAL=180
50
 
 
 
 
 
51
  # Include .env in backups (default: false)
52
  # SYNC_INCLUDE_ENV=false
53
+
54
+ # ── OPTIONAL: Startup Scripts & Package Install ──
55
+ # Run arbitrary bash on every boot (before Hermes gateway launches).
56
+ # Supports base64: prefix for quote-heavy scripts.
57
+ # HUGGINGMES_RUN="""
58
+ # sudo apt-get install -y ffmpeg
59
+ # pip install pandas
60
+ # """
61
+
62
+ # Install packages from day one (before terminal is opened)
63
+ # HUGGINGMES_APT_PACKAGES=ffmpeg,git-lfs
64
+ # HUGGINGMES_PIP_PACKAGES=pandas,requests
65
+ # HUGGINGMES_NPM_PACKAGES=typescript
66
+
67
+ # ── OPTIONAL: Gateway Restart Behavior ──
68
+ # Seconds to wait between gateway restarts (default: 5)
69
+ # GATEWAY_RESTART_DELAY=5
70
+
71
+ # Max gateway restarts before container exits (default: unlimited)
72
+ # GATEWAY_MAX_RESTARTS=10
73
+
74
+ # ── OPTIONAL: API Key Rotation ──
75
+ # Comma-separated key pools per provider for round-robin rotation.
76
+ # The first key in a pool is also promoted to the singular env var.
77
+ # ANTHROPIC_API_KEYS=sk-ant-key1,sk-ant-key2,sk-ant-key3
78
+ # OPENAI_API_KEYS=sk-openai-key1,sk-openai-key2
79
+ # GEMINI_API_KEYS=AIza-key1,AIza-key2
80
+ # DEEPSEEK_API_KEYS=key1,key2
81
+ # GROQ_API_KEYS=gsk_key1,gsk_key2
82
+ # MISTRAL_API_KEYS=key1,key2
83
+ # OPENROUTER_API_KEYS=sk-or-key1,sk-or-key2
84
+ # XAI_API_KEYS=key1,key2
85
+ # NVIDIA_API_KEYS=key1,key2
86
+ # COHERE_API_KEYS=key1,key2
87
+ # TOGETHER_API_KEYS=key1,key2
88
+ # CEREBRAS_API_KEYS=key1,key2
89
+
90
+ # ── OPTIONAL: Terminal (JupyterLab) ──
91
+ # Terminal is ON by default when GATEWAY_TOKEN is set.
92
+ # Set DEV_MODE=false to disable it entirely.
93
+ # DEV_MODE=false
94
+
95
+ # Override terminal password (optional β€” defaults to GATEWAY_TOKEN)
96
+ # JUPYTER_TOKEN=your_separate_terminal_token_here
97
+
98
+ # ── OPTIONAL: Advanced ──
99
+ # Webhook URL for restart/backup-failure notifications
100
+ # WEBHOOK_URL=https://your-webhook-endpoint.com/notify
README.md CHANGED
@@ -23,9 +23,9 @@ secrets:
23
  - name: CLOUDFLARE_WORKERS_TOKEN
24
  description: "Cloudflare API token for automatic Worker proxy and KeepAlive setup."
25
  - name: DEV_MODE
26
- description: "Set to 'true' to enable the JupyterLab terminal at /terminal/."
27
  - name: JUPYTER_TOKEN
28
- description: "Strong token to secure JupyterLab terminal access (required when DEV_MODE=true). Generate: openssl rand -hex 32"
29
  ---
30
 
31
  <!-- Badges -->
@@ -46,6 +46,8 @@ secrets:
46
  - [πŸ“± Telegram Setup](#-telegram-setup)
47
  - [🌐 Cloudflare Proxy](#-cloudflare-proxy)
48
  - [πŸ’Ύ Backup & Persistence](#-backup--persistence)
 
 
49
  - [πŸ’“ Staying Alive](#-staying-alive-recommended-on-free-hf-spaces)
50
  - [πŸ” Security & Advanced](#-security--advanced)
51
  - [πŸ’» Terminal Access (JupyterLab)](#-terminal-access-jupyterlab)
@@ -58,10 +60,14 @@ secrets:
58
  - 🧠 **Hermes Core:** Runs Hermes Agent for multi-turn chat, tools, memory, and agent workflows.
59
  - πŸ” **Secure by Default:** Protects the dashboard and API with a single gateway token.
60
  - 🌐 **Built-in Connectivity:** Adds Cloudflare Worker proxy support for Telegram and other blocked outbound traffic.
61
- - πŸ“Š **Dashboard:** Real-time view of uptime, sync health, and agent status at `/`.
62
  - πŸ’Ύ **Persistent Backup:** Syncs chats, config, and session data to a private HF Dataset.
63
  - ⏰ **Keep-Alive:** Can provision a cron-triggered Cloudflare Worker to keep the Space awake.
64
- - πŸ’» **Terminal Access:** Optional JupyterLab terminal at `/terminal/` for direct shell access (enable with `DEV_MODE=true`).
 
 
 
 
65
  - πŸ€– **Broad Provider Support:** Supports Hermes' native providers, direct API-key providers, OAuth providers, and custom OpenAI-compatible endpoints.
66
 
67
  ## πŸŽ₯ Video Tutorial
@@ -188,6 +194,56 @@ Hugging Face Spaces often block outbound calls to APIs used by Telegram and some
188
 
189
  Set `HF_TOKEN` with write access to enable backup. HuggingMes syncs workspace data to a private HF Dataset named `huggingmes-backup` every 600 seconds by default.
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  ## πŸ’“ Staying Alive
192
 
193
  With `CLOUDFLARE_WORKERS_TOKEN` set, HuggingMes can create a keep-alive worker that pings the Space's `/health` endpoint on a schedule so the free tier stays awake longer.
@@ -201,9 +257,12 @@ With `CLOUDFLARE_WORKERS_TOKEN` set, HuggingMes can create a keep-alive worker t
201
  | `CLOUDFLARE_WORKERS_TOKEN` | β€” | Cloudflare API token for proxying and keep-awake |
202
  | `SYNC_INTERVAL` | `600` | Backup frequency in seconds |
203
  | `CLOUDFLARE_KEEPALIVE_ENABLED` | `true` | Set `false` to disable keep-awake worker |
204
- | `TELEGRAM_MODE` | `webhook` | `webhook` or `polling` |
205
  | `DEV_MODE` | `true` | Set `false` to disable JupyterLab terminal at `/terminal/` |
206
  | `JUPYTER_TOKEN` | *(uses `GATEWAY_TOKEN`)* | Override terminal password (optional) |
 
 
 
207
 
208
  ## πŸ’» Terminal Access (JupyterLab)
209
 
@@ -237,7 +296,7 @@ docker compose up --build
237
  - **Dashboard (`/`)**: Real-time management and monitoring.
238
  - **Hermes App (`/app/`)**: Secure proxied access to the Hermes UI.
239
  - **API (`/v1/*`)**: Proxied OpenAI-compatible agent API.
240
- - **Terminal (`/terminal/`)**: JupyterLab terminal (requires `DEV_MODE=true`).
241
  - **Health Check (`/health`)**: Readiness probe for HF and keep-alive.
242
  - **Sync Engine**: Python background task for HF Dataset persistence.
243
 
 
23
  - name: CLOUDFLARE_WORKERS_TOKEN
24
  description: "Cloudflare API token for automatic Worker proxy and KeepAlive setup."
25
  - name: DEV_MODE
26
+ description: "Set to 'false' to disable the JupyterLab terminal at /terminal/. Terminal is on by default when GATEWAY_TOKEN is set."
27
  - name: JUPYTER_TOKEN
28
+ description: "Override terminal password (optional). Defaults to GATEWAY_TOKEN β€” no extra secret needed."
29
  ---
30
 
31
  <!-- Badges -->
 
46
  - [πŸ“± Telegram Setup](#-telegram-setup)
47
  - [🌐 Cloudflare Proxy](#-cloudflare-proxy)
48
  - [πŸ’Ύ Backup & Persistence](#-backup--persistence)
49
+ - [πŸ“¦ Ephemeral Package Re-install](#-ephemeral-package-re-install-optional)
50
+ - [πŸ”‘ API Key Rotation](#-api-key-rotation-optional)
51
  - [πŸ’“ Staying Alive](#-staying-alive-recommended-on-free-hf-spaces)
52
  - [πŸ” Security & Advanced](#-security--advanced)
53
  - [πŸ’» Terminal Access (JupyterLab)](#-terminal-access-jupyterlab)
 
60
  - 🧠 **Hermes Core:** Runs Hermes Agent for multi-turn chat, tools, memory, and agent workflows.
61
  - πŸ” **Secure by Default:** Protects the dashboard and API with a single gateway token.
62
  - 🌐 **Built-in Connectivity:** Adds Cloudflare Worker proxy support for Telegram and other blocked outbound traffic.
63
+ - πŸ“Š **Dashboard:** Real-time view of uptime, sync health, model, provider, and agent status at `/`.
64
  - πŸ’Ύ **Persistent Backup:** Syncs chats, config, and session data to a private HF Dataset.
65
  - ⏰ **Keep-Alive:** Can provision a cron-triggered Cloudflare Worker to keep the Space awake.
66
+ - πŸ’» **Terminal Out of the Box:** JupyterLab terminal at `/terminal/` auto-enabled when `GATEWAY_TOKEN` is set β€” no extra config needed.
67
+ - πŸ”„ **Self-Healing Gateway:** Gateway, dashboard, health server, and JupyterLab are all monitored and automatically restarted if they exit unexpectedly.
68
+ - πŸ“¦ **Ephemeral Package Replay:** Install packages from the terminal and they survive restarts β€” shell wrappers record `apt`/`pip`/`uv`/`npm`/`hermes` installs and replay them on every boot.
69
+ - πŸš€ **Startup Scripts:** Run arbitrary bash at boot via `HUGGINGMES_RUN` or `HUGGINGMES_APT/PIP/NPM_PACKAGES` variables.
70
+ - πŸ”‘ **API Key Pool Rotation:** Supply comma-separated key pools (e.g. `ANTHROPIC_API_KEYS=key1,key2`) and the first key is promoted automatically.
71
  - πŸ€– **Broad Provider Support:** Supports Hermes' native providers, direct API-key providers, OAuth providers, and custom OpenAI-compatible endpoints.
72
 
73
  ## πŸŽ₯ Video Tutorial
 
194
 
195
  Set `HF_TOKEN` with write access to enable backup. HuggingMes syncs workspace data to a private HF Dataset named `huggingmes-backup` every 600 seconds by default.
196
 
197
+ | Variable | Default | Description |
198
+ | :--- | :--- | :--- |
199
+ | `HF_TOKEN` | β€” | HF token with **Write** access |
200
+ | `BACKUP_DATASET_NAME` | `huggingmes-backup` | Dataset name for backup |
201
+ | `SYNC_INTERVAL` | `600` | Backup frequency in seconds |
202
+
203
+ ## πŸ“¦ Ephemeral Package Re-install *(Optional)*
204
+
205
+ Install packages in the terminal and they survive Space restarts β€” no extra config needed. Shell wrappers record every successful `apt install`, `pip install`, `uv pip install`, `npm install -g`, and `hermes plugins install` into `workspace/startup.sh`, which is backed up and replayed automatically on next boot.
206
+
207
+ For packages you want installed from day one (before the terminal is even opened), use the startup variables:
208
+
209
+ | Variable | What to put in it |
210
+ | :--- | :--- |
211
+ | `HUGGINGMES_RUN` | Full bash script to run on every startup (multi-line, heredocs, `if` blocks all work) |
212
+ | `HUGGINGMES_APT_PACKAGES` | Space-separated apt packages to install |
213
+ | `HUGGINGMES_PIP_PACKAGES` | Space-separated Python packages to install |
214
+ | `HUGGINGMES_NPM_PACKAGES` | Space-separated npm packages to install globally |
215
+
216
+ **Example:**
217
+
218
+ ```bash
219
+ HUGGINGMES_RUN="""
220
+ pip install pandas matplotlib
221
+ npm install -g tsx
222
+ sudo apt-get install -y ffmpeg
223
+ """
224
+ ```
225
+
226
+ For scripts with complex quoting, base64-encode them:
227
+
228
+ ```bash
229
+ # locally
230
+ base64 -w0 setup.sh
231
+ # HF Variable
232
+ HUGGINGMES_RUN=base64:<paste-output-here>
233
+ ```
234
+
235
+ ## πŸ”‘ API Key Rotation *(Optional)*
236
+
237
+ Spread requests across multiple API keys to avoid rate limits. Supply a comma-separated pool β€” the first key is promoted to the provider's singular env var, and Hermes picks it up automatically.
238
+
239
+ ```bash
240
+ ANTHROPIC_API_KEYS=sk-ant-key1,sk-ant-key2
241
+ OPENAI_API_KEYS=sk-oai-key1,sk-oai-key2
242
+ OPENROUTER_API_KEYS=sk-or-key1,sk-or-key2
243
+ ```
244
+
245
+ Supported pool vars: `OPENROUTER_API_KEYS`, `ANTHROPIC_API_KEYS`, `OPENAI_API_KEYS`, `GOOGLE_API_KEYS`, `GEMINI_API_KEYS`, `DEEPSEEK_API_KEYS`, `KIMI_API_KEYS`, `MINIMAX_API_KEYS`, `NVIDIA_API_KEYS`, `XAI_API_KEYS`, `KILOCODE_API_KEYS`, `GLM_API_KEYS`, `ARCEEAI_API_KEYS`, `DASHSCOPE_API_KEYS`, `GMI_API_KEYS`, `TOKENHUB_API_KEYS`.
246
+
247
  ## πŸ’“ Staying Alive
248
 
249
  With `CLOUDFLARE_WORKERS_TOKEN` set, HuggingMes can create a keep-alive worker that pings the Space's `/health` endpoint on a schedule so the free tier stays awake longer.
 
257
  | `CLOUDFLARE_WORKERS_TOKEN` | β€” | Cloudflare API token for proxying and keep-awake |
258
  | `SYNC_INTERVAL` | `600` | Backup frequency in seconds |
259
  | `CLOUDFLARE_KEEPALIVE_ENABLED` | `true` | Set `false` to disable keep-awake worker |
260
+ | `TELEGRAM_MODE` | `webhook` | `webhook` or `polling` (webhook auto-configured from `SPACE_HOST`) |
261
  | `DEV_MODE` | `true` | Set `false` to disable JupyterLab terminal at `/terminal/` |
262
  | `JUPYTER_TOKEN` | *(uses `GATEWAY_TOKEN`)* | Override terminal password (optional) |
263
+ | `WEBHOOK_URL` | β€” | Endpoint for POST JSON restart notifications |
264
+ | `GATEWAY_RESTART_DELAY` | `5` | Seconds between gateway restart attempts |
265
+ | `GATEWAY_MAX_RESTARTS` | `0` (unlimited) | Maximum gateway restart count before container exits |
266
 
267
  ## πŸ’» Terminal Access (JupyterLab)
268
 
 
296
  - **Dashboard (`/`)**: Real-time management and monitoring.
297
  - **Hermes App (`/app/`)**: Secure proxied access to the Hermes UI.
298
  - **API (`/v1/*`)**: Proxied OpenAI-compatible agent API.
299
+ - **Terminal (`/terminal/`)**: JupyterLab terminal (auto-enabled when `GATEWAY_TOKEN` is set; set `DEV_MODE=false` to disable).
300
  - **Health Check (`/health`)**: Readiness probe for HF and keep-alive.
301
  - **Sync Engine**: Python background task for HF Dataset persistence.
302
 
health-server.js CHANGED
@@ -1,6 +1,7 @@
1
  "use strict";
2
 
3
  const http = require("http");
 
4
  const fs = require("fs");
5
  const net = require("net");
6
  const crypto = require("crypto");
@@ -18,6 +19,59 @@ const APP_BASE = "/app";
18
  const LOGIN_PATH = "/login";
19
  const SESSION_COOKIE = "huggingmes_session";
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  const SYNC_STATUS_FILE = "/tmp/huggingmes-sync-status.json";
22
  const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
23
  "/tmp/huggingmes-cloudflare-keepalive-status.json";
@@ -347,6 +401,36 @@ async function statusPayload() {
347
  };
348
  }
349
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  function badge(label, state) {
351
  return `<span class="badge ${state ? "ok" : "off"}">${escapeHtml(label)}</span>`;
352
  }
@@ -529,9 +613,9 @@ function renderDashboard(data) {
529
  <div class="subtitle">Self-hosted - Hermes Agent</div>
530
  </header>
531
  <div class="hero-buttons">
532
- <a class="hero-action" href="${APP_BASE}/" target="_blank" rel="noopener noreferrer">Open Hermes Agent β†’</a>
533
- <a class="hero-action secondary" href="/terminal/" target="_blank" rel="noopener noreferrer">Open Terminal β†’</a>
534
- <a class="hero-action secondary" href="/env-builder" target="_blank" rel="noopener noreferrer">ENV Builder β†’</a>
535
  </div>
536
  <section class="overview">
537
  ${tiles}
@@ -545,6 +629,28 @@ function renderDashboard(data) {
545
  el.textContent = 'At ' + date.toLocaleTimeString();
546
  }
547
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
548
  </script>
549
  </body>
550
  </html>`;
@@ -559,6 +665,25 @@ const server = http.createServer(async (req, res) => {
559
  return;
560
  }
561
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
562
  if (path === "/health" || path === `${APP_BASE}/health`) {
563
  const data = await statusPayload();
564
  // Always 200 β€” health server up means the app is running.
@@ -610,6 +735,10 @@ const server = http.createServer(async (req, res) => {
610
  }
611
 
612
  if (path === "/") {
 
 
 
 
613
  const data = await statusPayload();
614
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
615
  res.end(renderDashboard(data));
 
1
  "use strict";
2
 
3
  const http = require("http");
4
+ const https = require("https");
5
  const fs = require("fs");
6
  const net = require("net");
7
  const crypto = require("crypto");
 
19
  const LOGIN_PATH = "/login";
20
  const SESSION_COOKIE = "huggingmes_session";
21
 
22
+ // ── Private Space redirect support ──
23
+ const SPACE_ID = (process.env.SPACE_ID || "").trim();
24
+ function deriveHfSpaceUrl() {
25
+ if (SPACE_ID) return `https://huggingface.co/spaces/${SPACE_ID}`;
26
+ const host = (process.env.SPACE_HOST || "").replace(/\.hf\.space$/i, "");
27
+ const author = (process.env.SPACE_AUTHOR_NAME || "").trim().toLowerCase();
28
+ if (author && host.toLowerCase().startsWith(author + "-")) {
29
+ const spaceName = host.slice(author.length + 1);
30
+ return `https://huggingface.co/spaces/${process.env.SPACE_AUTHOR_NAME}/${spaceName}`;
31
+ }
32
+ return "";
33
+ }
34
+ const HF_SPACE_URL = deriveHfSpaceUrl();
35
+
36
+ let SPACE_IS_PRIVATE = false;
37
+ async function detectSpacePrivacy() {
38
+ if (!SPACE_ID) return;
39
+ try {
40
+ const token = (process.env.HF_TOKEN || "").trim();
41
+ const reqOptions = {
42
+ hostname: "huggingface.co",
43
+ path: `/api/spaces/${SPACE_ID}`,
44
+ method: "GET",
45
+ headers: Object.assign(
46
+ { "User-Agent": "HuggingMes/health-server" },
47
+ token ? { Authorization: `Bearer ${token}` } : {}
48
+ ),
49
+ };
50
+ await new Promise((resolve) => {
51
+ const r = https.request(reqOptions, (res) => {
52
+ let body = "";
53
+ res.on("data", (chunk) => { body += chunk; });
54
+ res.on("end", () => {
55
+ try {
56
+ if (res.statusCode === 200) {
57
+ const data = JSON.parse(body);
58
+ SPACE_IS_PRIVATE = data.private === true;
59
+ } else if (res.statusCode === 404 && !token) {
60
+ SPACE_IS_PRIVATE = true;
61
+ }
62
+ } catch {}
63
+ resolve();
64
+ });
65
+ });
66
+ r.on("error", resolve);
67
+ r.setTimeout(5000, () => { r.destroy(); resolve(); });
68
+ r.end();
69
+ });
70
+ console.log(`[health-server] Space privacy: ${SPACE_IS_PRIVATE ? "private" : "public"}`);
71
+ } catch {}
72
+ }
73
+ detectSpacePrivacy();
74
+
75
  const SYNC_STATUS_FILE = "/tmp/huggingmes-sync-status.json";
76
  const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
77
  "/tmp/huggingmes-cloudflare-keepalive-status.json";
 
401
  };
402
  }
403
 
404
+ function renderPrivateRedirect(targetUrl) {
405
+ const safeUrl = escapeHtml(targetUrl);
406
+ return `<!doctype html><html lang="en"><head>
407
+ <meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
408
+ <meta http-equiv="refresh" content="3;url=${safeUrl}"/>
409
+ <title>HuggingMes β€” Private Space</title>
410
+ <style>
411
+ :root{color-scheme:dark}
412
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
413
+ font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;
414
+ background:#08080f;color:#f6f4ff;text-align:center;padding:24px}
415
+ .card{border:1px solid #26243a;background:#12111b;border-radius:14px;padding:36px 32px;max-width:440px}
416
+ h1{margin:0 0 12px;font-size:1.5rem}
417
+ p{color:#b8b3d7;line-height:1.6;margin:0 0 24px}
418
+ .btn{display:inline-flex;align-items:center;justify-content:center;
419
+ background:#fff;color:#000;font-weight:850;font-size:.95rem;
420
+ border-radius:8px;padding:12px 28px;text-decoration:none;transition:opacity .15s}
421
+ .btn:hover{opacity:.85}
422
+ .sub{color:#7f7a9e;font-size:.78rem;margin-top:16px}
423
+ </style></head><body>
424
+ <div class="card">
425
+ <h1>πŸ”’ Private Space</h1>
426
+ <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>
427
+ <a class="btn" href="${safeUrl}">Open on Hugging Face β†’</a>
428
+ <div class="sub">Redirecting in 3 seconds&hellip;</div>
429
+ </div>
430
+ <script>setTimeout(() => { window.location.replace(${JSON.stringify(targetUrl)}); }, 100);</script>
431
+ </body></html>`;
432
+ }
433
+
434
  function badge(label, state) {
435
  return `<span class="badge ${state ? "ok" : "off"}">${escapeHtml(label)}</span>`;
436
  }
 
613
  <div class="subtitle">Self-hosted - Hermes Agent</div>
614
  </header>
615
  <div class="hero-buttons">
616
+ <a class="hero-action" data-space-link="app" href="${APP_BASE}/">Open Hermes Agent β†’</a>
617
+ <a class="hero-action secondary" data-space-link="terminal" href="/terminal/">πŸ’» Open Terminal β†’</a>
618
+ <a class="hero-action secondary" data-space-link="env-builder" href="/env-builder">βš™οΈ ENV Builder β†’</a>
619
  </div>
620
  <section class="overview">
621
  ${tiles}
 
629
  el.textContent = 'At ' + date.toLocaleTimeString();
630
  }
631
  });
632
+ const inEmbeddedApp = (() => { try { return window.top !== window.self; } catch { return true; } })();
633
+ const isDirectHfSpaceHost = /\.hf\.space$/i.test(window.location.hostname);
634
+ const HF_SPACE_URL = ${JSON.stringify(HF_SPACE_URL)};
635
+ const SPACE_IS_PRIVATE = ${JSON.stringify(SPACE_IS_PRIVATE)};
636
+ if (SPACE_IS_PRIVATE && isDirectHfSpaceHost && !inEmbeddedApp && HF_SPACE_URL) {
637
+ const notice = document.createElement('div');
638
+ 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';
639
+ 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>';
640
+ document.body.appendChild(notice);
641
+ setTimeout(() => { window.location.replace(HF_SPACE_URL); }, 300);
642
+ }
643
+ // Force new-tab navigation when running inside the HF App iframe or on a raw .hf.space link
644
+ const openInNewTab = inEmbeddedApp || isDirectHfSpaceHost;
645
+ document.querySelectorAll('a[data-space-link]').forEach((a) => {
646
+ if (openInNewTab) {
647
+ a.setAttribute('target', '_blank');
648
+ a.setAttribute('rel', 'noopener noreferrer');
649
+ } else {
650
+ a.removeAttribute('target');
651
+ a.removeAttribute('rel');
652
+ }
653
+ });
654
  </script>
655
  </body>
656
  </html>`;
 
665
  return;
666
  }
667
 
668
+ // ── Private Space Guard (server-side) ──
669
+ // Intercepts browser HTML requests from raw .hf.space hosts when the Space is private.
670
+ // /health and /status are always exempt so uptime monitors keep working.
671
+ const isHtmlReq = (req.headers.accept || "").includes("text/html");
672
+ const isDirectHfSpaceReq = SPACE_IS_PRIVATE &&
673
+ HF_SPACE_URL &&
674
+ isHtmlReq &&
675
+ typeof req.headers.host === "string" &&
676
+ req.headers.host.endsWith(".hf.space");
677
+
678
+ if (path === "/hf-redirect" || path === "/hf-redirect/") {
679
+ if (HF_SPACE_URL) {
680
+ res.writeHead(302, { location: HF_SPACE_URL, "cache-control": "no-store" });
681
+ return res.end();
682
+ }
683
+ res.writeHead(404, { "content-type": "text/plain" });
684
+ return res.end("SPACE_ID not configured.");
685
+ }
686
+
687
  if (path === "/health" || path === `${APP_BASE}/health`) {
688
  const data = await statusPayload();
689
  // Always 200 β€” health server up means the app is running.
 
735
  }
736
 
737
  if (path === "/") {
738
+ if (isDirectHfSpaceReq) {
739
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
740
+ return res.end(renderPrivateRedirect(HF_SPACE_URL));
741
+ }
742
  const data = await statusPayload();
743
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
744
  res.end(renderDashboard(data));
start.sh CHANGED
@@ -17,6 +17,7 @@ TELEGRAM_WEBHOOK_PORT="${TELEGRAM_WEBHOOK_PORT:-8765}"
17
  SYNC_INTERVAL="${SYNC_INTERVAL:-600}"
18
  BACKUP_DATASET="${BACKUP_DATASET_NAME:-huggingmes-backup}"
19
  CF_PROXY_ENV_FILE="/tmp/huggingmes-cloudflare-proxy.env"
 
20
 
21
  export HERMES_HOME
22
  export API_SERVER_ENABLED="${API_SERVER_ENABLED:-true}"
@@ -223,6 +224,40 @@ if [ -n "${CLOUDFLARE_PROXY_URL:-}" ] && [ -z "$TELEGRAM_BASE_URL" ]; then
223
  export TELEGRAM_BASE_FILE_URL="${CLOUDFLARE_PROXY_URL}/file/bot"
224
  fi
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  # ── Build config ──
227
  python3 - <<'PY'
228
  import os
@@ -288,11 +323,19 @@ path.chmod(0o600)
288
  PY
289
 
290
  # ── Startup Summary ──
 
291
  echo ""
 
 
 
292
  echo "Model : ${MODEL_FOR_CONFIG:-unset}"
293
  echo "Provider : ${PROVIDER_FOR_CONFIG:-unset}"
294
  if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
295
- echo "Telegram : enabled"
 
 
 
 
296
  else
297
  echo "Telegram : not configured"
298
  fi
@@ -304,17 +347,22 @@ fi
304
  if [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
305
  echo "Proxy : ${CLOUDFLARE_PROXY_URL}"
306
  fi
 
307
  echo "Dashboard : http://127.0.0.1:${DASHBOARD_PORT}"
308
  echo "Gateway : http://127.0.0.1:${GATEWAY_API_PORT}"
309
  echo ""
310
 
311
- # ── JupyterLab terminal (on by default, uses GATEWAY_TOKEN) ──
 
312
  start_jupyter() {
313
  if [ "${DEV_MODE:-true}" = "false" ]; then
314
  echo "JupyterLab disabled (DEV_MODE=false)."
315
  return 0
316
  fi
317
- # Use JUPYTER_TOKEN if set, otherwise fall back to GATEWAY_TOKEN
 
 
 
318
  local token="${JUPYTER_TOKEN:-${API_SERVER_KEY:-}}"
319
  if [ -z "$token" ]; then
320
  echo "WARNING: No GATEWAY_TOKEN or JUPYTER_TOKEN set β€” JupyterLab skipped (terminal would be unauthenticated)." >&2
@@ -329,7 +377,7 @@ start_jupyter() {
329
  local root_dir="${JUPYTER_ROOT_DIR:-$HERMES_HOME/workspace}"
330
  mkdir -p "$root_dir"
331
  ln -sfn "$HERMES_HOME" "$root_dir/HuggingMes" 2>/dev/null || true
332
- echo "Starting JupyterLab terminal on port 8888 (path: /terminal/) root: $root_dir"
333
  "$VENV_PYTHON" -m jupyterlab \
334
  --ip 127.0.0.1 \
335
  --port 8888 \
@@ -355,6 +403,8 @@ start_jupyter() {
355
  }
356
 
357
  # ── Trap SIGTERM for graceful shutdown ──
 
 
358
  graceful_shutdown() {
359
  echo "Shutting down HuggingMes..."
360
  if [ -n "${HF_TOKEN:-}" ]; then
@@ -365,6 +415,283 @@ graceful_shutdown() {
365
  }
366
  trap graceful_shutdown SIGTERM SIGINT
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  # ── Start background services ──
369
  node "$APP_DIR/health-server.js" &
370
  HEALTH_PID=$!
@@ -383,47 +710,101 @@ urllib.request.urlopen(req, timeout=10).read()
383
  PY
384
  fi
385
 
386
- echo "Launching Hermes dashboard on 127.0.0.1:${DASHBOARD_PORT}..."
387
- (hermes dashboard --host 127.0.0.1 --insecure 2>&1 | tee -a "$HERMES_HOME/logs/dashboard.log") &
388
- DASHBOARD_PID=$!
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
 
390
- # ── Launch gateway ──
391
- echo "Launching Hermes gateway..."
392
- (hermes gateway run 2>&1 | tee -a "$HERMES_HOME/logs/gateway.log") &
393
- GATEWAY_PID=$!
394
 
 
 
 
 
395
  GATEWAY_READY_TIMEOUT="${GATEWAY_READY_TIMEOUT:-120}"
396
- ready=false
397
- for ((i=0; i<GATEWAY_READY_TIMEOUT; i++)); do
398
- if (echo > "/dev/tcp/127.0.0.1/${GATEWAY_API_PORT}") 2>/dev/null; then
399
- ready=true
400
- break
 
 
 
401
  fi
402
- if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
403
- break
 
 
 
404
  fi
405
- sleep 1
406
- done
407
 
408
- if [ "$ready" != "true" ]; then
409
- echo ""
410
- echo "Hermes gateway failed to expose the API health port. Last 40 log lines:"
411
- echo "----------------------------------------"
412
- tail -40 "$HERMES_HOME/logs/gateway.log" || true
413
- exit 1
414
- fi
415
 
416
- if [ -n "${HF_TOKEN:-}" ]; then
417
- python3 -u "$APP_DIR/hermes-sync.py" loop &
418
- fi
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
 
420
- start_jupyter
 
421
 
422
- wait "$GATEWAY_PID"
 
 
 
423
 
424
- # Gateway exited (e.g. user restarted from Hermes UI). Sync before container dies.
425
- # SIGTERM path is handled by graceful_shutdown trap above; this covers natural exit.
426
- if [ -n "${HF_TOKEN:-}" ]; then
427
- echo "Gateway exited β€” syncing state before shutdown..."
428
- python3 "$APP_DIR/hermes-sync.py" sync-once || echo "Warning: final sync failed."
429
- fi
 
 
 
 
 
 
 
 
 
 
17
  SYNC_INTERVAL="${SYNC_INTERVAL:-600}"
18
  BACKUP_DATASET="${BACKUP_DATASET_NAME:-huggingmes-backup}"
19
  CF_PROXY_ENV_FILE="/tmp/huggingmes-cloudflare-proxy.env"
20
+ STARTUP_FILE="$HERMES_HOME/workspace/startup.sh"
21
 
22
  export HERMES_HOME
23
  export API_SERVER_ENABLED="${API_SERVER_ENABLED:-true}"
 
224
  export TELEGRAM_BASE_FILE_URL="${CLOUDFLARE_PROXY_URL}/file/bot"
225
  fi
226
 
227
+ # ── Pool key promotion ──
228
+ # Mirror first key from comma-separated pool vars into the singular env var.
229
+ # Hermes providers read singular vars; this lets users supply pool keys like
230
+ # ANTHROPIC_API_KEYS=key1,key2 and have them picked up automatically.
231
+ promote_first_pool_key() {
232
+ local singular_var="$1"
233
+ local pool_var="$2"
234
+ local singular_val="${!singular_var:-}"
235
+ local pool_val="${!pool_var:-}"
236
+ [ -n "$singular_val" ] && return 0
237
+ [ -n "$pool_val" ] || return 0
238
+ local first
239
+ first=$(printf '%s' "$pool_val" | tr ',' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | awk 'NF{print; exit}')
240
+ [ -n "$first" ] || return 0
241
+ export "${singular_var}=$first"
242
+ }
243
+
244
+ promote_first_pool_key "OPENROUTER_API_KEY" "OPENROUTER_API_KEYS"
245
+ promote_first_pool_key "ANTHROPIC_API_KEY" "ANTHROPIC_API_KEYS"
246
+ promote_first_pool_key "OPENAI_API_KEY" "OPENAI_API_KEYS"
247
+ promote_first_pool_key "GOOGLE_API_KEY" "GOOGLE_API_KEYS"
248
+ promote_first_pool_key "GEMINI_API_KEY" "GEMINI_API_KEYS"
249
+ promote_first_pool_key "DEEPSEEK_API_KEY" "DEEPSEEK_API_KEYS"
250
+ promote_first_pool_key "KIMI_API_KEY" "KIMI_API_KEYS"
251
+ promote_first_pool_key "MINIMAX_API_KEY" "MINIMAX_API_KEYS"
252
+ promote_first_pool_key "NVIDIA_API_KEY" "NVIDIA_API_KEYS"
253
+ promote_first_pool_key "XAI_API_KEY" "XAI_API_KEYS"
254
+ promote_first_pool_key "KILOCODE_API_KEY" "KILOCODE_API_KEYS"
255
+ promote_first_pool_key "GLM_API_KEY" "GLM_API_KEYS"
256
+ promote_first_pool_key "ARCEEAI_API_KEY" "ARCEEAI_API_KEYS"
257
+ promote_first_pool_key "DASHSCOPE_API_KEY" "DASHSCOPE_API_KEYS"
258
+ promote_first_pool_key "GMI_API_KEY" "GMI_API_KEYS"
259
+ promote_first_pool_key "TOKENHUB_API_KEY" "TOKENHUB_API_KEYS"
260
+
261
  # ── Build config ──
262
  python3 - <<'PY'
263
  import os
 
323
  PY
324
 
325
  # ── Startup Summary ──
326
+ HERMES_RUNTIME_VERSION="$(/opt/hermes/.venv/bin/hermes --version 2>/dev/null | awk '{print $NF; exit}' || true)"
327
  echo ""
328
+ if [ -n "${HERMES_RUNTIME_VERSION:-}" ]; then
329
+ echo "Version : ${HERMES_RUNTIME_VERSION}"
330
+ fi
331
  echo "Model : ${MODEL_FOR_CONFIG:-unset}"
332
  echo "Provider : ${PROVIDER_FOR_CONFIG:-unset}"
333
  if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
334
+ if [ -n "${TELEGRAM_WEBHOOK_URL:-}" ]; then
335
+ echo "Telegram : webhook"
336
+ else
337
+ echo "Telegram : polling"
338
+ fi
339
  else
340
  echo "Telegram : not configured"
341
  fi
 
347
  if [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
348
  echo "Proxy : ${CLOUDFLARE_PROXY_URL}"
349
  fi
350
+ echo "Routes : /app/ (Hermes UI), /terminal/ (JupyterLab)"
351
  echo "Dashboard : http://127.0.0.1:${DASHBOARD_PORT}"
352
  echo "Gateway : http://127.0.0.1:${GATEWAY_API_PORT}"
353
  echo ""
354
 
355
+ # ── JupyterLab terminal (on by default when GATEWAY_TOKEN is set) ──
356
+ JUPYTER_PID=""
357
  start_jupyter() {
358
  if [ "${DEV_MODE:-true}" = "false" ]; then
359
  echo "JupyterLab disabled (DEV_MODE=false)."
360
  return 0
361
  fi
362
+ # Guard: skip if already running
363
+ if [ -n "${JUPYTER_PID:-}" ] && kill -0 "$JUPYTER_PID" 2>/dev/null; then
364
+ return 0
365
+ fi
366
  local token="${JUPYTER_TOKEN:-${API_SERVER_KEY:-}}"
367
  if [ -z "$token" ]; then
368
  echo "WARNING: No GATEWAY_TOKEN or JUPYTER_TOKEN set β€” JupyterLab skipped (terminal would be unauthenticated)." >&2
 
377
  local root_dir="${JUPYTER_ROOT_DIR:-$HERMES_HOME/workspace}"
378
  mkdir -p "$root_dir"
379
  ln -sfn "$HERMES_HOME" "$root_dir/HuggingMes" 2>/dev/null || true
380
+ echo "Starting JupyterLab terminal on port 8888 (root: $root_dir)"
381
  "$VENV_PYTHON" -m jupyterlab \
382
  --ip 127.0.0.1 \
383
  --port 8888 \
 
403
  }
404
 
405
  # ── Trap SIGTERM for graceful shutdown ──
406
+ SYNC_LOOP_PID=""
407
+ DASHBOARD_PID=""
408
  graceful_shutdown() {
409
  echo "Shutting down HuggingMes..."
410
  if [ -n "${HF_TOKEN:-}" ]; then
 
415
  }
416
  trap graceful_shutdown SIGTERM SIGINT
417
 
418
+ # ── Shell capture wrappers ──
419
+ # Written to ~/.bashrc so terminal installs are recorded in workspace/startup.sh
420
+ # and replayed on next boot β€” packages survive Space restarts.
421
+ if [ ! -f "$STARTUP_FILE" ]; then
422
+ touch "$STARTUP_FILE"
423
+ chmod +x "$STARTUP_FILE"
424
+ echo "Created workspace/startup.sh"
425
+ fi
426
+ cat > "$HOME/.bashrc" << 'BASHRC'
427
+ export PATH="/opt/hermes/.venv/bin:/opt/data/.local/bin:$PATH"
428
+ export DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"
429
+ if [ -z "${PS1:-}" ] || [ "$PS1" = "$ " ]; then
430
+ export PS1="\u@\h:\w\$ "
431
+ fi
432
+
433
+ HERMES_HOME="${HERMES_HOME:-/opt/data}"
434
+ STARTUP_FILE="$HERMES_HOME/workspace/startup.sh"
435
+
436
+ _hm_append() {
437
+ [ "${HUGGINGMES_CAPTURE_DISABLE:-0}" = "1" ] && return 0
438
+ local line="$*"
439
+ mkdir -p "$(dirname "$STARTUP_FILE")"
440
+ touch "$STARTUP_FILE"
441
+ chmod +x "$STARTUP_FILE" 2>/dev/null || true
442
+ grep -qxF "$line" "$STARTUP_FILE" 2>/dev/null || echo "$line" >> "$STARTUP_FILE"
443
+ }
444
+ _hm_quote_args() {
445
+ local quoted=()
446
+ local arg
447
+ for arg in "$@"; do
448
+ printf -v arg '%q' "$arg"
449
+ quoted+=("$arg")
450
+ done
451
+ printf '%s' "${quoted[*]}"
452
+ }
453
+ _hm_append_cmd() {
454
+ local cmd="$1"
455
+ shift
456
+ local args
457
+ args=$(_hm_quote_args "$@")
458
+ if [ -n "$args" ]; then
459
+ _hm_append "$cmd $args"
460
+ else
461
+ _hm_append "$cmd"
462
+ fi
463
+ }
464
+ _hm_args_without_flags() {
465
+ local out=()
466
+ for arg in "$@"; do
467
+ case "$arg" in
468
+ ''|-|--*|-*) ;;
469
+ *) out+=("$arg") ;;
470
+ esac
471
+ done
472
+ printf '%s\n' "${out[@]}"
473
+ }
474
+ _hm_has_install_targets() {
475
+ local item
476
+ while IFS= read -r item; do
477
+ [ -n "$item" ] && return 0
478
+ done <<EOF
479
+ $(_hm_args_without_flags "$@")
480
+ EOF
481
+ return 1
482
+ }
483
+ _hm_has_arg() {
484
+ local needle="$1"
485
+ shift
486
+ for arg in "$@"; do
487
+ [ "$arg" = "$needle" ] && return 0
488
+ done
489
+ return 1
490
+ }
491
+ _hm_can_sudo_apt() {
492
+ command -v sudo >/dev/null 2>&1 && sudo -n apt-get --version >/dev/null 2>&1
493
+ }
494
+ _hm_apt_install() {
495
+ if [ "$(id -u)" -eq 0 ]; then
496
+ command apt-get update && command apt-get install -y "$@"
497
+ elif _hm_can_sudo_apt; then
498
+ sudo apt-get update && sudo apt-get install -y "$@"
499
+ else
500
+ echo "Error: apt install needs root." >&2
501
+ return 1
502
+ fi
503
+ }
504
+ apt-get() {
505
+ case "${1:-}" in
506
+ install)
507
+ shift
508
+ _hm_apt_install "$@"
509
+ local rc=$?
510
+ if [ $rc -eq 0 ]; then
511
+ _hm_has_install_targets "$@" && _hm_append_cmd "sudo apt-get update && sudo apt-get install -y" "$@"
512
+ fi
513
+ return $rc
514
+ ;;
515
+ update)
516
+ if [ "$(id -u)" -eq 0 ]; then command apt-get "$@"
517
+ elif _hm_can_sudo_apt; then sudo apt-get "$@"
518
+ else command apt-get "$@"; fi
519
+ return $?
520
+ ;;
521
+ *) command apt-get "$@"; return $? ;;
522
+ esac
523
+ }
524
+ apt() {
525
+ case "${1:-}" in
526
+ install)
527
+ shift
528
+ _hm_apt_install "$@"
529
+ local rc=$?
530
+ if [ $rc -eq 0 ]; then
531
+ _hm_has_install_targets "$@" && _hm_append_cmd "sudo apt-get update && sudo apt-get install -y" "$@"
532
+ fi
533
+ return $rc
534
+ ;;
535
+ update)
536
+ if [ "$(id -u)" -eq 0 ]; then command apt "$@"
537
+ elif _hm_can_sudo_apt; then sudo apt "$@"
538
+ else command apt "$@"; fi
539
+ return $?
540
+ ;;
541
+ *) command apt "$@"; return $? ;;
542
+ esac
543
+ }
544
+ pip() {
545
+ command pip "$@"
546
+ local rc=$?
547
+ if [ $rc -eq 0 ] && [ "${1:-}" = "install" ] \
548
+ && ! _hm_has_arg -r "${@:2}" && ! _hm_has_arg --requirement "${@:2}" \
549
+ && _hm_has_install_targets "${@:2}"; then
550
+ _hm_append_cmd "pip install" "${@:2}"
551
+ fi
552
+ return $rc
553
+ }
554
+ pip3() {
555
+ command pip3 "$@"
556
+ local rc=$?
557
+ if [ $rc -eq 0 ] && [ "${1:-}" = "install" ] \
558
+ && ! _hm_has_arg -r "${@:2}" && ! _hm_has_arg --requirement "${@:2}" \
559
+ && _hm_has_install_targets "${@:2}"; then
560
+ _hm_append_cmd "pip install" "${@:2}"
561
+ fi
562
+ return $rc
563
+ }
564
+ uv() {
565
+ command uv "$@"
566
+ local rc=$?
567
+ if [ $rc -eq 0 ] && [ "${1:-}" = "pip" ] && [ "${2:-}" = "install" ] \
568
+ && ! _hm_has_arg -r "${@:3}" && ! _hm_has_arg --requirements "${@:3}" \
569
+ && _hm_has_install_targets "${@:3}"; then
570
+ _hm_append_cmd "uv pip install" "${@:3}"
571
+ fi
572
+ return $rc
573
+ }
574
+ npm() {
575
+ command npm "$@"
576
+ local rc=$?
577
+ if [ $rc -eq 0 ] && { [ "${1:-}" = "install" ] || [ "${1:-}" = "i" ]; } && { [ "${2:-}" = "-g" ] || [ "${2:-}" = "--global" ]; } && _hm_has_install_targets "${@:3}"; then
578
+ _hm_append_cmd "npm install -g" "${@:3}"
579
+ fi
580
+ return $rc
581
+ }
582
+ hermes() {
583
+ command hermes "$@"
584
+ local rc=$?
585
+ if [ $rc -eq 0 ] && [ "${1:-}" = "plugins" ] && [ "${2:-}" = "install" ] && _hm_has_install_targets "${@:3}"; then
586
+ _hm_append_cmd "hermes plugins install" "${@:3}"
587
+ fi
588
+ return $rc
589
+ }
590
+ BASHRC
591
+ cat > "$HOME/.profile" << 'PROFILE'
592
+ [ -n "${BASH_VERSION:-}" ] && [ -f ~/.bashrc ] && . ~/.bashrc
593
+ PROFILE
594
+ echo "Shell capture wrappers ready."
595
+
596
+ # ── Optional package installs from HF Variables/Secrets ──
597
+ HM_STARTUP_FAILURES=0
598
+
599
+ if [ -n "${HUGGINGMES_APT_PACKAGES:-}" ]; then
600
+ echo "Installing apt packages from HUGGINGMES_APT_PACKAGES..."
601
+ read -r -a HM_APT_PACKAGES <<< "$HUGGINGMES_APT_PACKAGES"
602
+ if command -v sudo >/dev/null 2>&1; then
603
+ if sudo apt-get update && sudo apt-get install -y "${HM_APT_PACKAGES[@]}"; then
604
+ echo "HUGGINGMES_APT_PACKAGES install complete."
605
+ else
606
+ HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))
607
+ echo "ERROR: HUGGINGMES_APT_PACKAGES install failed: ${HUGGINGMES_APT_PACKAGES}" >&2
608
+ fi
609
+ elif [ "$(id -u)" -eq 0 ]; then
610
+ if apt-get update && apt-get install -y "${HM_APT_PACKAGES[@]}"; then
611
+ echo "HUGGINGMES_APT_PACKAGES install complete."
612
+ else
613
+ HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))
614
+ echo "ERROR: HUGGINGMES_APT_PACKAGES install failed: ${HUGGINGMES_APT_PACKAGES}" >&2
615
+ fi
616
+ else
617
+ HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))
618
+ echo "ERROR: root/sudo unavailable; HUGGINGMES_APT_PACKAGES skipped" >&2
619
+ fi
620
+ fi
621
+
622
+ if [ -n "${HUGGINGMES_PIP_PACKAGES:-}" ]; then
623
+ echo "Installing Python packages from HUGGINGMES_PIP_PACKAGES..."
624
+ read -r -a HM_PIP_PACKAGES <<< "$HUGGINGMES_PIP_PACKAGES"
625
+ if /opt/hermes/.venv/bin/pip install "${HM_PIP_PACKAGES[@]}"; then
626
+ echo "HUGGINGMES_PIP_PACKAGES install complete."
627
+ else
628
+ HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))
629
+ echo "ERROR: HUGGINGMES_PIP_PACKAGES install failed: ${HUGGINGMES_PIP_PACKAGES}" >&2
630
+ fi
631
+ fi
632
+
633
+ if [ -n "${HUGGINGMES_NPM_PACKAGES:-}" ]; then
634
+ echo "Installing npm packages from HUGGINGMES_NPM_PACKAGES..."
635
+ read -r -a HM_NPM_PACKAGES <<< "$HUGGINGMES_NPM_PACKAGES"
636
+ if npm install -g "${HM_NPM_PACKAGES[@]}"; then
637
+ echo "HUGGINGMES_NPM_PACKAGES install complete."
638
+ else
639
+ HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))
640
+ echo "ERROR: HUGGINGMES_NPM_PACKAGES install failed: ${HUGGINGMES_NPM_PACKAGES}" >&2
641
+ fi
642
+ fi
643
+
644
+ # ── Arbitrary startup script (HUGGINGMES_RUN) ──
645
+ # Supports plain bash or base64-encoded scripts (prefix with base64: or b64:).
646
+ # Example: HUGGINGMES_RUN="pip install pandas && npm install -g typescript"
647
+ # Example: HUGGINGMES_RUN="base64:$(base64 -w0 setup.sh)"
648
+ hm_run_startup_auto() {
649
+ local payload="$1"
650
+ [ -n "$payload" ] || return 0
651
+ local script_file
652
+ script_file=$(mktemp "/tmp/huggingmes-startup.XXXXXX.sh")
653
+ {
654
+ echo 'export HUGGINGMES_CAPTURE_DISABLE=1'
655
+ echo '[ -f ~/.bashrc ] && . ~/.bashrc'
656
+ if [[ "$payload" == base64:* ]] || [[ "$payload" == b64:* ]]; then
657
+ printf '%s' "${payload#*:}" | base64 -d
658
+ else
659
+ printf '%s\n' "$payload"
660
+ fi
661
+ } > "$script_file"
662
+ chmod 700 "$script_file"
663
+ echo "[startup:HUGGINGMES_RUN] running script"
664
+ set +e
665
+ bash "$script_file"
666
+ local rc=$?
667
+ set -e
668
+ rm -f "$script_file"
669
+ if [ $rc -eq 0 ]; then
670
+ echo "[startup:HUGGINGMES_RUN] ok"
671
+ else
672
+ HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))
673
+ echo "ERROR: HUGGINGMES_RUN script failed (exit ${rc})" >&2
674
+ fi
675
+ }
676
+
677
+ if [ -n "${HUGGINGMES_RUN:-}" ]; then
678
+ hm_run_startup_auto "$HUGGINGMES_RUN"
679
+ fi
680
+
681
+ # ── Run workspace startup script ──
682
+ # Replays install commands recorded by the shell wrappers from previous sessions.
683
+ if [ -s "$STARTUP_FILE" ]; then
684
+ echo "Running workspace/startup.sh..."
685
+ set +e
686
+ HUGGINGMES_CAPTURE_DISABLE=1 bash -l "$STARTUP_FILE"
687
+ set -e
688
+ echo "Workspace startup script complete."
689
+ fi
690
+
691
+ if [ "$HM_STARTUP_FAILURES" -gt 0 ]; then
692
+ echo "Warning: ${HM_STARTUP_FAILURES} startup step(s) failed. Check logs above." >&2
693
+ fi
694
+
695
  # ── Start background services ──
696
  node "$APP_DIR/health-server.js" &
697
  HEALTH_PID=$!
 
710
  PY
711
  fi
712
 
713
+ # ── Launch dashboard once (restarts if it dies) ──
714
+ start_dashboard_once() {
715
+ if [ -n "${DASHBOARD_PID:-}" ] && kill -0 "$DASHBOARD_PID" 2>/dev/null; then
716
+ return 0
717
+ fi
718
+ echo "Launching Hermes dashboard on 127.0.0.1:${DASHBOARD_PORT}..."
719
+ (hermes dashboard --host 127.0.0.1 --insecure 2>&1 | tee -a "$HERMES_HOME/logs/dashboard.log") &
720
+ DASHBOARD_PID=$!
721
+ }
722
+
723
+ # ── Start sync loop once β€” survives gateway restarts ──
724
+ start_background_sync_once() {
725
+ [ -n "${HF_TOKEN:-}" ] || return 0
726
+ if [ -n "${SYNC_LOOP_PID:-}" ] && kill -0 "$SYNC_LOOP_PID" 2>/dev/null; then
727
+ return 0
728
+ fi
729
+ python3 -u "$APP_DIR/hermes-sync.py" loop &
730
+ SYNC_LOOP_PID=$!
731
+ }
732
 
733
+ start_dashboard_once
734
+ start_jupyter
 
 
735
 
736
+ # ── Gateway restart loop ──
737
+ GATEWAY_RESTART_DELAY="${GATEWAY_RESTART_DELAY:-5}"
738
+ GATEWAY_MAX_RESTARTS="${GATEWAY_MAX_RESTARTS:-0}"
739
+ GATEWAY_RESTART_COUNT=0
740
  GATEWAY_READY_TIMEOUT="${GATEWAY_READY_TIMEOUT:-120}"
741
+
742
+ while true; do
743
+ # Monitor health-server β€” restart if it died unexpectedly
744
+ if [ -n "${HEALTH_PID:-}" ] && ! kill -0 "$HEALTH_PID" 2>/dev/null; then
745
+ echo "Warning: health-server exited (PID $HEALTH_PID dead); restarting..."
746
+ node "$APP_DIR/health-server.js" &
747
+ HEALTH_PID=$!
748
+ echo "Health server restarted (PID: $HEALTH_PID)"
749
  fi
750
+
751
+ # Monitor Hermes dashboard β€” restart if it died unexpectedly
752
+ if [ -n "${DASHBOARD_PID:-}" ] && ! kill -0 "$DASHBOARD_PID" 2>/dev/null; then
753
+ echo "Warning: Hermes dashboard exited; restarting..."
754
+ start_dashboard_once
755
  fi
 
 
756
 
757
+ # Monitor JupyterLab β€” restart if it died unexpectedly
758
+ if [ "${DEV_MODE:-true}" != "false" ] && [ -n "${JUPYTER_PID:-}" ] && ! kill -0 "$JUPYTER_PID" 2>/dev/null; then
759
+ echo "Warning: JupyterLab exited (PID $JUPYTER_PID dead); restarting..."
760
+ unset JUPYTER_PID
761
+ start_jupyter
762
+ fi
 
763
 
764
+ echo "Launching Hermes gateway..."
765
+ (hermes gateway run 2>&1 | tee -a "$HERMES_HOME/logs/gateway.log") &
766
+ GATEWAY_PID=$!
767
+
768
+ ready=false
769
+ for ((i=0; i<GATEWAY_READY_TIMEOUT; i++)); do
770
+ if (echo > "/dev/tcp/127.0.0.1/${GATEWAY_API_PORT}") 2>/dev/null; then
771
+ ready=true
772
+ break
773
+ fi
774
+ if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
775
+ break
776
+ fi
777
+ sleep 1
778
+ done
779
+
780
+ if [ "$ready" != "true" ]; then
781
+ echo ""
782
+ echo "Hermes gateway failed to expose the API health port. Last 40 log lines:"
783
+ echo "----------------------------------------"
784
+ tail -40 "$HERMES_HOME/logs/gateway.log" || true
785
+ exit 1
786
+ fi
787
 
788
+ # Start sync loop (only once β€” shared across all gateway restarts)
789
+ start_background_sync_once
790
 
791
+ set +e
792
+ wait "$GATEWAY_PID"
793
+ GATEWAY_EXIT_CODE=$?
794
+ set -e
795
 
796
+ # Sync state before restart
797
+ if [ -n "${HF_TOKEN:-}" ]; then
798
+ echo "Gateway exited β€” syncing state before restart..."
799
+ python3 "$APP_DIR/hermes-sync.py" sync-once || echo "Warning: sync failed."
800
+ fi
801
+
802
+ GATEWAY_RESTART_COUNT=$((GATEWAY_RESTART_COUNT + 1))
803
+ if [ "$GATEWAY_MAX_RESTARTS" != "0" ] && [ "$GATEWAY_RESTART_COUNT" -ge "$GATEWAY_MAX_RESTARTS" ]; then
804
+ echo "Gateway exited (code ${GATEWAY_EXIT_CODE}); restart limit (${GATEWAY_MAX_RESTARTS}) reached."
805
+ exit "$GATEWAY_EXIT_CODE"
806
+ fi
807
+
808
+ echo "Gateway exited (code ${GATEWAY_EXIT_CODE}); restarting in ${GATEWAY_RESTART_DELAY}s..."
809
+ sleep "$GATEWAY_RESTART_DELAY"
810
+ done