From 2a99ef1336a46efc547df05a48bc3a9438da1c37 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 9 Jun 2026 07:45:43 +0000 Subject: [PATCH 1/5] feat: improve Nexus integration - Add dappnode-nexus skill with context-length pitfall docs - Seed Nexus skill on first boot in entrypoint.sh - Auto-set model.context_length=1000000 for Nexus to avoid 256K fallback - Add suggested Nexus models in setup wizard with context_length metadata - Include context_length in generated config.yaml for Nexus - Update dappnode SKILL.md with context-length pitfall reference - Bump version to 0.1.5 --- dappnode/dappnode-nexus/SKILL.md | 77 ++++++++++++++++++++++++++++++++ dappnode/dappnode/SKILL.md | 2 + dappnode_package.json | 2 +- docker-compose.yml | 2 +- entrypoint.sh | 21 +++++++++ setup-wizard/index.html | 35 +++++++++++++-- 6 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 dappnode/dappnode-nexus/SKILL.md diff --git a/dappnode/dappnode-nexus/SKILL.md b/dappnode/dappnode-nexus/SKILL.md new file mode 100644 index 0000000..bc10310 --- /dev/null +++ b/dappnode/dappnode-nexus/SKILL.md @@ -0,0 +1,77 @@ +--- +name: dappnode-nexus +description: DAppNode Nexus — Private AI Gateway for Builders. Knowledge about the Nexus platform, its architecture, API compatibility, context-length pitfalls, and integration with DAppNode infrastructure. +category: devops +tags: + - dappnode + - nexus + - ai-gateway + - privacy + - openai-compatible +--- + +# DAppNode Nexus + +Nexus is DAppNode's **Private AI Gateway** — a unified, OpenAI-compatible API for accessing private and confidential AI models. + +## Core Value Proposition + +- **Privacy-first**: Prompts and data stay within the user's infrastructure +- **OpenAI-compatible API**: Drop-in replacement for OpenAI API calls +- **Multiple model routing**: Access to models from DeepSeek, Anthropic, OpenAI, Minimax, and more +- **Flexible billing**: Pay-as-you-go or subscription models + +## Architecture + +Nexus runs as a service within the DAppNode ecosystem. Users access it via: +- **Web UI**: https://nexus.dappnode.com/ +- **API endpoint**: `https://nexus-api.dappnode.com/v1` + +## Key URLs + +| Resource | URL | +|----------|-----| +| Nexus Web App | https://nexus.dappnode.com/ | +| Nexus API | https://nexus-api.dappnode.com/v1 | +| DAppNode Main Site | https://dappnode.com/ | + +## Privacy Guarantees + +- Inference runs on DAppNode infrastructure, not external cloud providers +- Data does not leave the user's controlled environment +- No logging or retention of prompts by default + +## Pitfalls + +### Context length defaults to 256K with Nexus provider + +When Nexus is configured as the Hermes provider (`nexus-api.dappnode.com`), Hermes cannot auto-detect the model's true context length because: + +1. `nexus-api.dappnode.com` is not in Hermes' `_URL_TO_PROVIDER` map → treated as an unknown custom endpoint +2. The `/v1/models` endpoint returns `403 Forbidden` (Nexus does not expose model metadata publicly) +3. Hermes skips all provider-aware lookups and falls back to `DEFAULT_FALLBACK_CONTEXT = 256_000` tokens + +**Symptom**: Hermes compresses context early, treats a 1M-token model as 256K, or shows `context_length: 256000` in `/usage`. + +**Fix**: Set the correct context length explicitly in config: + +```bash +hermes config set model.context_length 1000000 +``` + +After setting, do `/reset` for the change to take effect. + +Common Nexus-proxied models and their context lengths: + +| Model | Context length | +|-------|---------------| +| `deepseek/deepseek-v4-pro` | 1,000,000 | +| `deepseek/deepseek-v4-flash` | 1,000,000 | +| `deepseek/deepseek-r1-0528` | 1,000,000 | +| `anthropic/claude-sonnet-4` | 200,000 | +| `anthropic/claude-opus-4` | 200,000 | +| `openai/gpt-5` | 400,000 | +| `openai/gpt-5.4` | 1,050,000 | +| `minimax/minimax-m2.7` | 1,000,000 | + +For the full resolution chain and root-cause analysis, see `references/nexus-context-length-resolution.md`. diff --git a/dappnode/dappnode/SKILL.md b/dappnode/dappnode/SKILL.md index d955e43..63792d7 100644 --- a/dappnode/dappnode/SKILL.md +++ b/dappnode/dappnode/SKILL.md @@ -70,6 +70,8 @@ To configure, use the Setup Wizard at `http://hermes-agent.dappnode:8080` and se 1. Sign up at `https://nexus.dappnode.com` and create an API key 2. In the Setup Wizard or `config.yaml`, set the provider to Nexus with base URL `https://nexus-api.dappnode.com/v1` +**Context length pitfall**: Nexus uses a custom domain (`nexus-api.dappnode.com`) that Hermes cannot auto-resolve for context length detection — models default to 256K tokens. The DAppNode package automatically sets `model.context_length` to 1M for new setups, but if you see early context compression, run `hermes config set model.context_length 1000000`. See the `dappnode-nexus` skill for the full root-cause analysis and per-model context lengths. + ## Troubleshooting ### Package Not Reachable diff --git a/dappnode_package.json b/dappnode_package.json index 71670b1..bf2325b 100644 --- a/dappnode_package.json +++ b/dappnode_package.json @@ -1,6 +1,6 @@ { "name": "hermes-agent.dnp.dappnode.eth", - "version": "0.1.4", + "version": "0.1.5", "upstreamVersion": "v2026.5.29.2", "upstreamRepo": "NousResearch/hermes-agent", "upstreamArg": "UPSTREAM_VERSION", diff --git a/docker-compose.yml b/docker-compose.yml index 80bcd5d..c14817f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: dockerfile: Dockerfile args: UPSTREAM_VERSION: v2026.5.29.2 - image: hermes-agent.dnp.dappnode.eth:0.1.0 + image: hermes-agent.dnp.dappnode.eth:0.1.5 container_name: DAppNodePackage-hermes-agent.dnp.dappnode.eth restart: unless-stopped environment: diff --git a/entrypoint.sh b/entrypoint.sh index 0f0bc33..ffa8cc2 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -129,6 +129,11 @@ if [ ! -d "$HERMES_HOME/skills/devops/dappnode" ] && [ -d /opt/dappnode/dappnode cp -r /opt/dappnode/dappnode/* "$HERMES_HOME/skills/devops/dappnode/" echo "Seeded DAppNode skill" fi +if [ ! -d "$HERMES_HOME/skills/devops/dappnode-nexus" ] && [ -d /opt/dappnode/dappnode-nexus ]; then + mkdir -p "$HERMES_HOME/skills/devops/dappnode-nexus" + cp -r /opt/dappnode/dappnode-nexus/* "$HERMES_HOME/skills/devops/dappnode-nexus/" + echo "Seeded DAppNode Nexus skill" +fi # --- DAppNode: patch config.yaml for network access --- python3 -c " @@ -158,6 +163,22 @@ if isinstance(platforms, dict): with open(config_path, 'w') as f: yaml.dump(config, f, default_flow_style=False, sort_keys=False) print('Patched config.yaml for DAppNode (api_port=3000, whatsapp_bridge_port=3010)') + +# --- Nexus context length fix --- +# nexus-api.dappnode.com is not in Hermes' URL-to-provider map, so the agent +# cannot auto-detect model context length and falls back to 256K. Pre-set a +# sensible default unless the user has already configured it explicitly. +model_section = config.get('model', {}) +provider = model_section.get('provider', '') +base_url = model_section.get('base_url', '') +ctx_set = model_section.get('context_length') is not None +if not ctx_set and provider == 'custom' and 'nexus-api.dappnode.com' in str(base_url): + model_section['context_length'] = 1000000 + with open(config_path, 'w') as f: + yaml.dump(config, f, default_flow_style=False, sort_keys=False) + print('Nexus detected: set model.context_length=1000000 to avoid 256K fallback') +elif ctx_set and provider == 'custom' and 'nexus-api.dappnode.com' in str(base_url): + print(f'Nexus detected: context_length already set to {config[\"model\"][\"context_length\"]}, leaving as-is') " || echo "Warning: Could not patch config.yaml, continuing with defaults" # --- Sync bundled skills (matches upstream — no || true) --- diff --git a/setup-wizard/index.html b/setup-wizard/index.html index 7ce98a5..58fa868 100644 --- a/setup-wizard/index.html +++ b/setup-wizard/index.html @@ -423,6 +423,17 @@

Configuration Saved!

// Suggested models per provider (shown as autocomplete hints — user can type anything) const SUGGESTED_MODELS = { + nexus: [ + { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro", context_length: 1000000 }, + { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash", context_length: 1000000 }, + { id: "deepseek/deepseek-r1-0528", name: "DeepSeek R1", context_length: 1000000 }, + { id: "anthropic/claude-sonnet-4", name: "Claude Sonnet 4", context_length: 200000 }, + { id: "anthropic/claude-opus-4", name: "Claude Opus 4", context_length: 200000 }, + { id: "openai/gpt-5.4", name: "GPT-5.4", context_length: 1050000 }, + { id: "openai/gpt-5", name: "GPT-5", context_length: 400000 }, + { id: "minimax/minimax-m2.7", name: "MiniMax M2.7", context_length: 1000000 }, + { id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro", context_length: 2000000 }, + ], openai: ["gpt-4.1", "gpt-4.1-mini", "gpt-5.2", "o4-mini", "o3", "gpt-4o"], anthropic: ["claude-sonnet-4-5-20250929", "claude-opus-4-6", "claude-haiku-4-5-20251001"], google: ["gemini-3-pro-preview", "gemini-3-flash-preview", "gemini-2.5-pro", "gemini-2.5-flash"], @@ -555,10 +566,20 @@

Configure DAppNode Nexus

-
Browse the available models at nexus.dappnode.com/models and paste the full provider/model ID below.
- +
Select a suggested model or type any model ID supported by Nexus. Browse all models at nexus.dappnode.com/models.
+
`; + const mc = document.getElementById("nexus-model-container"); + const suggestions = SUGGESTED_MODELS.nexus || []; + mc.appendChild(createModelInput("nexus-model", suggestions, "e.g. deepseek/deepseek-v4-pro")); + // Pre-select DeepSeek V4 Pro as a sensible default + if (suggestions.length) { + setTimeout(() => { + const inp = document.getElementById("nexus-model-input"); + if (inp) inp.value = suggestions[0].id; + }, 0); + } return; } @@ -740,7 +761,7 @@

Configure ${p.name}

if (apiKey) env.NEXUS_API_KEY = apiKey; env.OPENAI_BASE_URL = "https://nexus-api.dappnode.com/v1"; env.OPENAI_API_KEY = apiKey; - env.LLM_MODEL = getModelValue() || "minimax/minimax-m2.7"; + env.LLM_MODEL = getModelValue() || "deepseek/deepseek-v4-pro"; } else if (p.id === "ollama") { const url = (document.getElementById("ollama-url").value || "").trim() || "http://ollama.dappnode:11434"; env.OPENAI_BASE_URL = url + "/v1"; @@ -787,9 +808,15 @@

Configure ${p.name}

"", "model:" ]; if (p.id === "nexus") { - lines.push(` default: "${getModelValue() || "minimax/minimax-m2.7"}"`); + const model = getModelValue() || "deepseek/deepseek-v4-pro"; + lines.push(` default: "${model}"`); lines.push(` provider: "custom"`); lines.push(` base_url: "https://nexus-api.dappnode.com/v1"`); + // Set context_length based on the selected model to avoid 256K fallback + const nexusSuggestions = SUGGESTED_MODELS.nexus || []; + const matched = nexusSuggestions.find(m => m.id === model); + const ctx = matched ? matched.context_length : 1000000; + lines.push(` context_length: ${ctx}`); } else if (p.id === "ollama") { const url = (document.getElementById("ollama-url").value || "").trim() || "http://ollama.dappnode:11434"; lines.push(` default: "${getModelValue() || "llama3"}"`); From 1401cbb530720ee9385252e4bb14843f9fbcb3e3 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 9 Jun 2026 08:08:56 +0000 Subject: [PATCH 2/5] fix: correct Nexus models from live API data - Fix MiniMax M2.7 context from 1M to 204800 (real API data) - Add MiniMax M3 (512K) and Kimi K2.6 (262K) to suggestions - Remove models not available on Nexus (Claude, GPT, Gemini, R1) - Add dynamic Nexus model fetching via /api/models/nexus endpoint - server.cjs: add fetchNexusModels() with 1h cache + /api/models/nexus route - HTML: fetchNexusModels() loads live models; SUGGESTED_MODELS as fallback - buildConfigYaml: use live Nexus models for context_length lookup - Fix dappnode-nexus skill: remove 403 Forbidden claim (endpoint works) --- dappnode/dappnode-nexus/SKILL.md | 26 +++++++------- setup-wizard/index.html | 58 +++++++++++++++++++------------- setup-wizard/server.cjs | 42 +++++++++++++++++++++++ 3 files changed, 90 insertions(+), 36 deletions(-) diff --git a/dappnode/dappnode-nexus/SKILL.md b/dappnode/dappnode-nexus/SKILL.md index bc10310..a8b0e96 100644 --- a/dappnode/dappnode-nexus/SKILL.md +++ b/dappnode/dappnode-nexus/SKILL.md @@ -45,11 +45,13 @@ Nexus runs as a service within the DAppNode ecosystem. Users access it via: ### Context length defaults to 256K with Nexus provider -When Nexus is configured as the Hermes provider (`nexus-api.dappnode.com`), Hermes cannot auto-detect the model's true context length because: +When Nexus is configured as the Hermes provider (`nexus-api.dappnode.com`), Hermes may not auto-detect the model's true context length because: 1. `nexus-api.dappnode.com` is not in Hermes' `_URL_TO_PROVIDER` map → treated as an unknown custom endpoint -2. The `/v1/models` endpoint returns `403 Forbidden` (Nexus does not expose model metadata publicly) -3. Hermes skips all provider-aware lookups and falls back to `DEFAULT_FALLBACK_CONTEXT = 256_000` tokens +2. Hermes may skip provider-aware lookups (Anthropic API, models.dev, hardcoded defaults) +3. Falls back to `DEFAULT_FALLBACK_CONTEXT = 256_000` tokens if auto-detection fails + +> **Update**: The `/v1/models` endpoint now returns `context_size` per model. The DAppNode package auto-sets `model.context_length` as a safety net, but you can verify with `hermes config show`. **Symptom**: Hermes compresses context early, treats a 1M-token model as 256K, or shows `context_length: 256000` in `/usage`. @@ -65,13 +67,11 @@ Common Nexus-proxied models and their context lengths: | Model | Context length | |-------|---------------| -| `deepseek/deepseek-v4-pro` | 1,000,000 | -| `deepseek/deepseek-v4-flash` | 1,000,000 | -| `deepseek/deepseek-r1-0528` | 1,000,000 | -| `anthropic/claude-sonnet-4` | 200,000 | -| `anthropic/claude-opus-4` | 200,000 | -| `openai/gpt-5` | 400,000 | -| `openai/gpt-5.4` | 1,050,000 | -| `minimax/minimax-m2.7` | 1,000,000 | - -For the full resolution chain and root-cause analysis, see `references/nexus-context-length-resolution.md`. +| `deepseek/deepseek-v4-pro` | 1,048,576 (1M) | +| `deepseek/deepseek-v4-flash` | 1,048,576 (1M) | +| `moonshotai/kimi-k2.6` | 262,144 | +| `minimax/minimax-m2.7` | 204,800 | +| `minimax/minmax-m3` | 512,000 | +| `nexus/auto` | Auto-routing (varies) | + +> **Note**: `/v1/models` endpoint is now publicly accessible and returns `context_size` per model. Hermes Agent can query this for auto-detection, but the DAppNode package also pre-sets a safe default. diff --git a/setup-wizard/index.html b/setup-wizard/index.html index 58fa868..e2786df 100644 --- a/setup-wizard/index.html +++ b/setup-wizard/index.html @@ -424,15 +424,12 @@

Configuration Saved!

// Suggested models per provider (shown as autocomplete hints — user can type anything) const SUGGESTED_MODELS = { nexus: [ - { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro", context_length: 1000000 }, - { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash", context_length: 1000000 }, - { id: "deepseek/deepseek-r1-0528", name: "DeepSeek R1", context_length: 1000000 }, - { id: "anthropic/claude-sonnet-4", name: "Claude Sonnet 4", context_length: 200000 }, - { id: "anthropic/claude-opus-4", name: "Claude Opus 4", context_length: 200000 }, - { id: "openai/gpt-5.4", name: "GPT-5.4", context_length: 1050000 }, - { id: "openai/gpt-5", name: "GPT-5", context_length: 400000 }, - { id: "minimax/minimax-m2.7", name: "MiniMax M2.7", context_length: 1000000 }, - { id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro", context_length: 2000000 }, + { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro", context_length: 1048576 }, + { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash", context_length: 1048576 }, + { id: "moonshotai/kimi-k2.6", name: "Kimi K2.6", context_length: 262144 }, + { id: "minimax/minmax-m3", name: "MiniMax M3", context_length: 512000 }, + { id: "minimax/minimax-m2.7", name: "MiniMax M2.7", context_length: 204800 }, + { id: "nexus/auto", name: "Auto Router", context_length: 1000000 }, ], openai: ["gpt-4.1", "gpt-4.1-mini", "gpt-5.2", "o4-mini", "o3", "gpt-4o"], anthropic: ["claude-sonnet-4-5-20250929", "claude-opus-4-6", "claude-haiku-4-5-20251001"], @@ -446,6 +443,7 @@

Configuration Saved!

let currentStep = 0; let selectedProvider = null; let openRouterModels = []; + let nexusModels = []; // ========================================================================= // Render provider cards @@ -566,20 +564,11 @@

Configure DAppNode Nexus

-
Select a suggested model or type any model ID supported by Nexus. Browse all models at nexus.dappnode.com/models.
-
+
Models are fetched live from Nexus. Browse all at nexus.dappnode.com/models.
+
Loading models from Nexus...
`; - const mc = document.getElementById("nexus-model-container"); - const suggestions = SUGGESTED_MODELS.nexus || []; - mc.appendChild(createModelInput("nexus-model", suggestions, "e.g. deepseek/deepseek-v4-pro")); - // Pre-select DeepSeek V4 Pro as a sensible default - if (suggestions.length) { - setTimeout(() => { - const inp = document.getElementById("nexus-model-input"); - if (inp) inp.value = suggestions[0].id; - }, 0); - } + fetchNexusModels(); return; } @@ -716,6 +705,28 @@

Configure ${p.name}

} } + async function fetchNexusModels() { + try { + const resp = await fetch("/api/models/nexus"); + const data = await resp.json(); + nexusModels = data.models || []; + } catch { + nexusModels = []; + } + const mc = document.getElementById("nexus-model-container"); + if (!mc) return; + mc.innerHTML = ""; + const suggestions = nexusModels.length ? nexusModels : (SUGGESTED_MODELS.nexus || []); + mc.appendChild(createModelInput("nexus-model", suggestions, + nexusModels.length ? `Search ${nexusModels.length} models...` : "e.g. deepseek/deepseek-v4-pro")); + const inp = document.getElementById("nexus-model-input"); + if (inp && suggestions.length) { + // Pre-select DeepSeek V4 Pro if available, otherwise first model + const ds = suggestions.find(m => m.id === "deepseek/deepseek-v4-pro"); + inp.value = ds ? ds.id : suggestions[0].id; + } + } + async function probeOllama() { const btn = document.getElementById("probe-btn"); const icon = document.getElementById("probe-icon"); @@ -813,8 +824,9 @@

Configure ${p.name}

lines.push(` provider: "custom"`); lines.push(` base_url: "https://nexus-api.dappnode.com/v1"`); // Set context_length based on the selected model to avoid 256K fallback - const nexusSuggestions = SUGGESTED_MODELS.nexus || []; - const matched = nexusSuggestions.find(m => m.id === model); + // Check live Nexus models first, then fall back to static suggestions + const allNexusSuggestions = nexusModels.length ? nexusModels : (SUGGESTED_MODELS.nexus || []); + const matched = allNexusSuggestions.find(m => m.id === model); const ctx = matched ? matched.context_length : 1000000; lines.push(` context_length: ${ctx}`); } else if (p.id === "ollama") { diff --git a/setup-wizard/server.cjs b/setup-wizard/server.cjs index 343e877..d84b959 100644 --- a/setup-wizard/server.cjs +++ b/setup-wizard/server.cjs @@ -22,6 +22,10 @@ const OLLAMA_CANDIDATES = [ let openRouterCache = { models: [], ts: 0 }; const CACHE_TTL = 6 * 60 * 60 * 1000; +// In-memory cache for Nexus models (refresh every 1 hour — models change less often) +let nexusCache = { models: [], ts: 0 }; +const NEXUS_CACHE_TTL = 60 * 60 * 1000; + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -139,6 +143,37 @@ async function fetchOpenRouterModels() { } } +/** + * Fetch models from Nexus public API. + * Returns sorted array of { id, name, context_length }. + */ +async function fetchNexusModels() { + const now = Date.now(); + if (nexusCache.models.length && (now - nexusCache.ts) < NEXUS_CACHE_TTL) { + return nexusCache.models; + } + try { + const resp = await fetch("https://nexus-api.dappnode.com/v1/models", { + signal: AbortSignal.timeout(10000), + headers: { "Accept": "application/json" }, + }); + if (!resp.ok) return nexusCache.models; + const data = await resp.json(); + const models = (data.data || []) + .filter((m) => m.id && m.kind !== "router") // exclude nexus/auto router + .map((m) => ({ + id: m.id, + name: m.display_name || m.id, + context_length: m.context_size || 0, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + nexusCache = { models, ts: now }; + return models; + } catch { + return nexusCache.models; + } +} + /** * Run `hermes status` and return the output. */ @@ -232,6 +267,13 @@ const server = http.createServer(async (req, res) => { return; } + // Fetch Nexus models (public API, cached) + if (req.method === "GET" && url.pathname === "/api/models/nexus") { + const models = await fetchNexusModels(); + json(res, 200, { models }); + return; + } + // Hermes status if (req.method === "GET" && url.pathname === "/api/status") { const status = await getHermesStatus(); From 52ec87eeb51425bf463ebf98e6c100753298d1f0 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 9 Jun 2026 08:16:31 +0000 Subject: [PATCH 3/5] bump: update nexus to v2026.6.5 --- dappnode_package.json | 14 ++++++++++---- docker-compose.yml | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/dappnode_package.json b/dappnode_package.json index bf2325b..69f0eeb 100644 --- a/dappnode_package.json +++ b/dappnode_package.json @@ -1,16 +1,22 @@ { "name": "hermes-agent.dnp.dappnode.eth", "version": "0.1.5", - "upstreamVersion": "v2026.5.29.2", + "upstreamVersion": "v2026.6.5", "upstreamRepo": "NousResearch/hermes-agent", "upstreamArg": "UPSTREAM_VERSION", "shortDescription": "Self-improving AI agent with multi-LLM support and messaging gateway", "description": "Hermes Agent is a self-improving AI agent built by Nous Research. It features a built-in learning loop — creating skills from experience, improving them during use, and building a deepening model of who you are across sessions.\n\n- **Web Gateway**: Full-featured web interface for interacting with AI\n- **Multi-LLM Support**: OpenRouter, OpenAI, Anthropic, Google Gemini, Ollama, Groq, and more\n- **Messaging Gateway**: Telegram, Discord, Slack, WhatsApp, Signal — all from a single process\n- **Skills System**: Agent-curated procedural memory that self-improves\n- **Persistent Memory**: Cross-session recall with user modeling\n- **Cron Scheduling**: Automated tasks with delivery to any platform\n- **Subagent Delegation**: Spawn isolated subagents for parallel workstreams\n\nRun your own AI agent with full control over your data and API keys.", "type": "service", - "architectures": ["linux/amd64"], + "architectures": [ + "linux/amd64" + ], "author": "DAppNode Association ", "license": "MIT", - "categories": ["AI", "Developer tools", "Communications"], + "categories": [ + "AI", + "Developer tools", + "Communications" + ], "keywords": [ "ai", "llm", @@ -79,4 +85,4 @@ "featuredBackground": "linear-gradient(135deg, #FFD700 0%, #764ba2 100%)", "featuredColor": "white" } -} +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index c14817f..438352a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: context: . dockerfile: Dockerfile args: - UPSTREAM_VERSION: v2026.5.29.2 + UPSTREAM_VERSION: v2026.6.5 image: hermes-agent.dnp.dappnode.eth:0.1.5 container_name: DAppNodePackage-hermes-agent.dnp.dappnode.eth restart: unless-stopped From db6f71156b406d1db86e7566db239e4ff31d84eb Mon Sep 17 00:00:00 2001 From: hcastc00 Date: Wed, 17 Jun 2026 16:30:02 +0200 Subject: [PATCH 4/5] feat: migrate to s6-overlay supervision and source Nexus context_length from /v1/models --- Dockerfile | 16 +- dappnode/patch-config.py | 94 +++ docker-compose.yml | 5 + entrypoint.sh | 204 ------ rootfs/etc/cont-init.d/10-dappnode-setup | 58 ++ .../s6-rc.d/setup-wizard/dependencies.d/base | 0 .../etc/s6-overlay/s6-rc.d/setup-wizard/run | 18 + .../etc/s6-overlay/s6-rc.d/setup-wizard/type | 1 + .../s6-rc.d/ttyd/dependencies.d/base | 0 rootfs/etc/s6-overlay/s6-rc.d/ttyd/run | 9 + rootfs/etc/s6-overlay/s6-rc.d/ttyd/type | 1 + .../s6-rc.d/user/contents.d/setup-wizard | 0 .../s6-overlay/s6-rc.d/user/contents.d/ttyd | 0 setup-wizard/index.html | 667 ++++++++++++++---- setup-wizard/server.cjs | 10 +- 15 files changed, 749 insertions(+), 334 deletions(-) create mode 100644 dappnode/patch-config.py delete mode 100644 entrypoint.sh create mode 100644 rootfs/etc/cont-init.d/10-dappnode-setup create mode 100644 rootfs/etc/s6-overlay/s6-rc.d/setup-wizard/dependencies.d/base create mode 100644 rootfs/etc/s6-overlay/s6-rc.d/setup-wizard/run create mode 100644 rootfs/etc/s6-overlay/s6-rc.d/setup-wizard/type create mode 100644 rootfs/etc/s6-overlay/s6-rc.d/ttyd/dependencies.d/base create mode 100644 rootfs/etc/s6-overlay/s6-rc.d/ttyd/run create mode 100644 rootfs/etc/s6-overlay/s6-rc.d/ttyd/type create mode 100644 rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/setup-wizard create mode 100644 rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/ttyd diff --git a/Dockerfile b/Dockerfile index b88a1b1..3b0d9de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,8 +16,16 @@ COPY setup-wizard/ /opt/setup-wizard/ # Copy DAppNode context files (seeded into HERMES_HOME on first boot) COPY dappnode/ /opt/dappnode/ -# Copy entrypoint script -COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh +# DAppNode s6-overlay customizations: a cont-init bootstrap hook plus the +# setup-wizard and ttyd long-run services. We deliberately do NOT override the +# image ENTRYPOINT — the upstream image runs s6-overlay's /init (which handles +# UID remap, chown, config seeding, schema migration, skills sync and drops to +# the hermes user via main-wrapper.sh). Overriding it and re-dropping privileges +# ourselves crash-looped the container (tini is symlinked to /init upstream). +COPY rootfs/ / +RUN chmod 0755 /etc/cont-init.d/10-dappnode-setup \ + /etc/s6-overlay/s6-rc.d/setup-wizard/run \ + /etc/s6-overlay/s6-rc.d/ttyd/run # Persistent data directory ENV HERMES_HOME=/opt/data @@ -29,5 +37,7 @@ EXPOSE 3000 8080 8081 7681 HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \ CMD curl -f http://localhost:3000/health || exit 1 -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +# ENTRYPOINT is inherited from the upstream image (s6-overlay /init + +# docker/main-wrapper.sh). main-wrapper routes the CMD below to +# `s6-setuidgid hermes hermes gateway run`. CMD ["gateway", "run"] diff --git a/dappnode/patch-config.py b/dappnode/patch-config.py new file mode 100644 index 0000000..9b93d10 --- /dev/null +++ b/dappnode/patch-config.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Patch a freshly-seeded Hermes config.yaml for the DAppNode environment. + +Run as the `hermes` user from the 10-dappnode-setup cont-init hook, AFTER +upstream's stage2-hook has seeded config.yaml from cli-config.yaml.example +and run its schema migration. Idempotent: safe to run on every boot. +""" +import json +import os +import urllib.request + +import yaml + +config_path = os.path.join(os.environ.get("HERMES_HOME", "/opt/data"), "config.yaml") + + +def fetch_nexus_context_size(base_url, model_id): + """Return the context_size Nexus reports for model_id, or None. + + Queries the OpenAI-compatible ``{base_url}/models`` listing, which Nexus + serves publicly with a ``context_size`` field per model. + """ + url = base_url.rstrip("/") + "/models" + try: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.load(resp) + except Exception: + return None + for m in data.get("data", []): + if m.get("id") == model_id: + size = m.get("context_size") + return int(size) if size else None + return None + +try: + with open(config_path) as f: + config = yaml.safe_load(f) or {} +except FileNotFoundError: + # Nothing to patch — upstream seeding should have created it, but don't + # fail the boot if it hasn't. + raise SystemExit(0) +except Exception: + config = {} + +# --- Network access: bind the gateway to the LAN on the DAppNode port --- +gw = config.setdefault("gateway", {}) +gw["port"] = 3000 +gw["bind"] = "lan" +cui = gw.setdefault("controlUi", {}) +cui.setdefault("dangerouslyAllowHostHeaderOriginFallback", True) +cui.setdefault("allowInsecureAuth", True) +cui.setdefault("dangerouslyDisableDeviceAuth", True) + +term = config.setdefault("terminal", {}) +term["cwd"] = os.environ.get("HERMES_HOME", "/opt/data") + +platforms = config.setdefault("platforms", {}) +if isinstance(platforms, dict): + whatsapp = platforms.setdefault("whatsapp", {}) + if isinstance(whatsapp, dict): + extra = whatsapp.setdefault("extra", {}) + if isinstance(extra, dict) and extra.get("bridge_port") in (None, 3000, "3000"): + extra["bridge_port"] = 3010 + +with open(config_path, "w") as f: + yaml.dump(config, f, default_flow_style=False, sort_keys=False) +print("Patched config.yaml for DAppNode (api_port=3000, whatsapp_bridge_port=3010)") + +# --- Nexus context length: source the real value from /v1/models --- +# nexus-api.dappnode.com is not in Hermes' URL-to-provider map, so the agent +# cannot auto-detect a model's context window and falls back to 256K. Rather +# than hardcode a single number (wrong for the smaller models -- e.g. Kimi is +# 262K, MiniMax M2.7 is 205K), query the endpoint Nexus already exposes: +# GET /v1/models returns `context_size` per model. Set model.context_length to +# that authoritative value for the configured model. +model_section = config.setdefault("model", {}) +provider = model_section.get("provider", "") +base_url = str(model_section.get("base_url", "")) +model_id = model_section.get("default") or model_section.get("model") or "" + +if provider == "custom" and "nexus-api.dappnode.com" in base_url and model_id: + ctx = fetch_nexus_context_size(base_url, model_id) + if ctx and model_section.get("context_length") != ctx: + model_section["context_length"] = ctx + with open(config_path, "w") as f: + yaml.dump(config, f, default_flow_style=False, sort_keys=False) + print(f"Nexus: set model.context_length={ctx} for '{model_id}' (from /v1/models)") + elif ctx: + print(f"Nexus: model.context_length already {ctx} for '{model_id}', leaving as-is") + else: + # Endpoint unreachable or model not listed. Leave context_length alone: + # Hermes' own 256K fallback is safe (under-, never over-estimating). + print(f"Nexus: could not resolve context_size for '{model_id}'; using Hermes default") diff --git a/docker-compose.yml b/docker-compose.yml index 438352a..da0ddc1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,11 @@ services: API_SERVER_KEY: dappnode API_SERVER_CORS_ORIGINS: "*" GATEWAY_ALLOW_ALL_USERS: "true" + # Enable the upstream s6 dashboard service on the DAppNode port (8081). + HERMES_DASHBOARD: "true" + HERMES_DASHBOARD_HOST: 0.0.0.0 + HERMES_DASHBOARD_PORT: "8081" + HERMES_DASHBOARD_INSECURE: "true" volumes: - hermes_data:/opt/data logging: diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100644 index ffa8cc2..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,204 +0,0 @@ -#!/bin/bash -# DAppNode Hermes Agent entrypoint -# Based on upstream docker/entrypoint.sh (v2026.5.7) with DAppNode additions. -set -e - -HERMES_HOME="${HERMES_HOME:-/opt/data}" -INSTALL_DIR="/opt/hermes" - -# Make `hermes` discoverable inside the ttyd web terminal. This must be -# installed before dropping privileges because /etc/profile.d is root-owned. -install_terminal_profile() { - if [ ! -d /etc/profile.d ] || [ -f /etc/profile.d/hermes-venv.sh ]; then - return - fi - - cat > /etc/profile.d/hermes-venv.sh <<'PROFILE' -# DAppNode Hermes Agent: expose venv + HERMES_HOME to login shells (ttyd) -if [ -d "/opt/hermes/.venv/bin" ]; then - export PATH="/opt/hermes/.venv/bin:$PATH" - export VIRTUAL_ENV="/opt/hermes/.venv" -fi -export HERMES_HOME="${HERMES_HOME:-/opt/data}" -export HOME="$HERMES_HOME/home" -mkdir -p "$HOME" 2>/dev/null || true -cd "$HERMES_HOME" -PROFILE - chmod 0644 /etc/profile.d/hermes-venv.sh -} - -# Drop root privileges using the mechanism bundled by the upstream Hermes image. -# Recent upstream images removed gosu and use s6-overlay's s6-setuidgid instead. -drop_to_hermes() { - if command -v s6-setuidgid >/dev/null 2>&1; then - exec env HOME="$HERMES_HOME/home" USER=hermes LOGNAME=hermes s6-setuidgid hermes "$0" "$@" - fi - - if [ -x /command/s6-setuidgid ]; then - exec env HOME="$HERMES_HOME/home" USER=hermes LOGNAME=hermes /command/s6-setuidgid hermes "$0" "$@" - fi - - echo "Error: s6-setuidgid not found; cannot drop root privileges." >&2 - echo "The upstream Hermes image should provide s6-setuidgid via s6-overlay." >&2 - exit 127 -} - -# --- Root preflight and privilege drop --- -# Upstream Hermes starts the official image as root only long enough to repair -# the mounted data volume and then re-enters as the non-root `hermes` user. -if [ "$(id -u)" = "0" ]; then - install_terminal_profile - - if [ -n "${HERMES_UID:-}" ] && [ "$HERMES_UID" != "$(id -u hermes)" ]; then - echo "Changing hermes UID to $HERMES_UID" - usermod -u "$HERMES_UID" hermes - fi - - if [ -n "${HERMES_GID:-}" ] && [ "$HERMES_GID" != "$(id -g hermes)" ]; then - echo "Changing hermes GID to $HERMES_GID" - groupmod -o -g "$HERMES_GID" hermes 2>/dev/null || true - fi - - mkdir -p "$HERMES_HOME" - - actual_hermes_uid="$(id -u hermes)" - needs_chown=false - if [ -n "${HERMES_UID:-}" ] && [ "$HERMES_UID" != "10000" ]; then - needs_chown=true - elif [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$actual_hermes_uid" ]; then - needs_chown=true - elif find "$HERMES_HOME" -xdev -maxdepth 3 ! -uid "$actual_hermes_uid" -print -quit 2>/dev/null | grep -q .; then - needs_chown=true - fi - - if [ "$needs_chown" = true ]; then - echo "Fixing ownership of $HERMES_HOME to hermes ($actual_hermes_uid)" - chown -R hermes:hermes "$HERMES_HOME" 2>/dev/null || \ - echo "Warning: chown failed (rootless container?) — continuing anyway" - fi - - if [ -f "$HERMES_HOME/config.yaml" ]; then - chown hermes:hermes "$HERMES_HOME/config.yaml" 2>/dev/null || true - chmod 640 "$HERMES_HOME/config.yaml" 2>/dev/null || true - fi - - echo "Dropping root privileges" - drop_to_hermes "$@" -fi - -# If this script is PID 1 after the privilege drop, insert tini while still -# running as `hermes`. That keeps signal forwarding/zombie reaping without -# making the setup wizard signal a root-owned PID 1 on restart. -if [ "${DAPPNODE_TINI_WRAPPED:-}" != "1" ] && [ "$$" = "1" ] && command -v tini >/dev/null 2>&1; then - export DAPPNODE_TINI_WRAPPED=1 - exec tini -g -- "$0" "$@" -fi - -# --- Running as hermes from here --- -export HOME="$HERMES_HOME/home" -export USER="${USER:-hermes}" -export LOGNAME="${LOGNAME:-hermes}" - -# --- Activate virtualenv --- -source "${INSTALL_DIR}/.venv/bin/activate" - -# Clean stale runtime files from previous container runs -rm -f "$HERMES_HOME"/gateway.lock "$HERMES_HOME"/gateway.pid - -# --- Bootstrap config files (mirrors upstream entrypoint) --- -mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills,skins,plans,workspace,home} - -if [ ! -f "$HERMES_HOME/.env" ]; then - cp "$INSTALL_DIR/.env.example" "$HERMES_HOME/.env" -fi -if [ ! -f "$HERMES_HOME/config.yaml" ]; then - cp "$INSTALL_DIR/cli-config.yaml.example" "$HERMES_HOME/config.yaml" -fi - -if [ ! -f "$HERMES_HOME/SOUL.md" ]; then - cp "$INSTALL_DIR/docker/SOUL.md" "$HERMES_HOME/SOUL.md" -fi - -# --- DAppNode: seed context files (first boot only) --- -if [ ! -f "$HERMES_HOME/.hermes.md" ] && [ -f /opt/dappnode/hermes.md ]; then - cp /opt/dappnode/hermes.md "$HERMES_HOME/.hermes.md" - echo "Seeded .hermes.md with DAppNode context" -fi -if [ ! -d "$HERMES_HOME/skills/devops/dappnode" ] && [ -d /opt/dappnode/dappnode ]; then - mkdir -p "$HERMES_HOME/skills/devops/dappnode" - cp -r /opt/dappnode/dappnode/* "$HERMES_HOME/skills/devops/dappnode/" - echo "Seeded DAppNode skill" -fi -if [ ! -d "$HERMES_HOME/skills/devops/dappnode-nexus" ] && [ -d /opt/dappnode/dappnode-nexus ]; then - mkdir -p "$HERMES_HOME/skills/devops/dappnode-nexus" - cp -r /opt/dappnode/dappnode-nexus/* "$HERMES_HOME/skills/devops/dappnode-nexus/" - echo "Seeded DAppNode Nexus skill" -fi - -# --- DAppNode: patch config.yaml for network access --- -python3 -c " -import yaml -config_path = '$HERMES_HOME/config.yaml' -try: - with open(config_path) as f: - config = yaml.safe_load(f) or {} -except Exception: - config = {} -gw = config.setdefault('gateway', {}) -gw['port'] = 3000 -gw['bind'] = 'lan' -cui = gw.setdefault('controlUi', {}) -cui.setdefault('dangerouslyAllowHostHeaderOriginFallback', True) -cui.setdefault('allowInsecureAuth', True) -cui.setdefault('dangerouslyDisableDeviceAuth', True) -term = config.setdefault('terminal', {}) -term['cwd'] = '$HERMES_HOME' -platforms = config.setdefault('platforms', {}) -if isinstance(platforms, dict): - whatsapp = platforms.setdefault('whatsapp', {}) - if isinstance(whatsapp, dict): - extra = whatsapp.setdefault('extra', {}) - if isinstance(extra, dict) and extra.get('bridge_port') in (None, 3000, '3000'): - extra['bridge_port'] = 3010 -with open(config_path, 'w') as f: - yaml.dump(config, f, default_flow_style=False, sort_keys=False) -print('Patched config.yaml for DAppNode (api_port=3000, whatsapp_bridge_port=3010)') - -# --- Nexus context length fix --- -# nexus-api.dappnode.com is not in Hermes' URL-to-provider map, so the agent -# cannot auto-detect model context length and falls back to 256K. Pre-set a -# sensible default unless the user has already configured it explicitly. -model_section = config.get('model', {}) -provider = model_section.get('provider', '') -base_url = model_section.get('base_url', '') -ctx_set = model_section.get('context_length') is not None -if not ctx_set and provider == 'custom' and 'nexus-api.dappnode.com' in str(base_url): - model_section['context_length'] = 1000000 - with open(config_path, 'w') as f: - yaml.dump(config, f, default_flow_style=False, sort_keys=False) - print('Nexus detected: set model.context_length=1000000 to avoid 256K fallback') -elif ctx_set and provider == 'custom' and 'nexus-api.dappnode.com' in str(base_url): - print(f'Nexus detected: context_length already set to {config[\"model\"][\"context_length\"]}, leaving as-is') -" || echo "Warning: Could not patch config.yaml, continuing with defaults" - -# --- Sync bundled skills (matches upstream — no || true) --- -if [ -d "$INSTALL_DIR/skills" ]; then - python3 "$INSTALL_DIR/tools/skills_sync.py" -fi - -# --- Start DAppNode background services --- -node /opt/setup-wizard/server.cjs & -echo "Setup wizard started on port 8080" - -hermes dashboard --port 8081 --host 0.0.0.0 --no-open --insecure & -echo "Web dashboard started on port 8081" - -ttyd -p 7681 -W bash -l & -echo "Web terminal started on port 7681" - -# --- Run hermes (matches upstream exec logic) --- -# If $1 is an executable on PATH, run it directly; otherwise wrap with `hermes`. -if [ $# -gt 0 ] && command -v "$1" >/dev/null 2>&1; then - exec "$@" -fi -exec hermes "$@" diff --git a/rootfs/etc/cont-init.d/10-dappnode-setup b/rootfs/etc/cont-init.d/10-dappnode-setup new file mode 100644 index 0000000..45a88bd --- /dev/null +++ b/rootfs/etc/cont-init.d/10-dappnode-setup @@ -0,0 +1,58 @@ +#!/command/with-contenv sh +# shellcheck shell=sh +# DAppNode cont-init hook. Runs as root during s6-overlay stage 2, AFTER +# upstream's 01-hermes-setup (UID/GID remap, chown, config.yaml + .env + SOUL.md +# seeding, schema migration, skills sync), 015-supervise-perms and +# 02-reconcile-profiles. We only do the genuinely DAppNode-specific bits here; +# everything generic is already handled upstream. +set -e + +HERMES_HOME="${HERMES_HOME:-/opt/data}" +INSTALL_DIR="/opt/hermes" + +# Drop to the hermes user for file ops that must end up hermes-owned. +as_hermes() { + s6-setuidgid hermes "$@" +} + +# --- Expose venv + HERMES_HOME to ttyd login shells ------------------------- +# ttyd launches `bash -l`, which sources /etc/profile.d/*.sh. This makes the +# `hermes` command and HERMES_HOME available in the web terminal. /etc/profile.d +# is root-owned, so this must run as root (here, not in a service). +if [ -d /etc/profile.d ] && [ ! -f /etc/profile.d/hermes-venv.sh ]; then + cat > /etc/profile.d/hermes-venv.sh <<'PROFILE' +# DAppNode Hermes Agent: expose venv + HERMES_HOME to login shells (ttyd) +if [ -d "/opt/hermes/.venv/bin" ]; then + export PATH="/opt/hermes/.venv/bin:$PATH" + export VIRTUAL_ENV="/opt/hermes/.venv" +fi +export HERMES_HOME="${HERMES_HOME:-/opt/data}" +export HOME="$HERMES_HOME/home" +mkdir -p "$HOME" 2>/dev/null || true +cd "$HERMES_HOME" +PROFILE + chmod 0644 /etc/profile.d/hermes-venv.sh +fi + +# --- Seed DAppNode context files (first boot only) -------------------------- +if [ ! -f "$HERMES_HOME/.hermes.md" ] && [ -f /opt/dappnode/hermes.md ]; then + as_hermes cp /opt/dappnode/hermes.md "$HERMES_HOME/.hermes.md" + echo "[dappnode] Seeded .hermes.md with DAppNode context" +fi +if [ ! -d "$HERMES_HOME/skills/devops/dappnode" ] && [ -d /opt/dappnode/dappnode ]; then + as_hermes mkdir -p "$HERMES_HOME/skills/devops/dappnode" + as_hermes cp -r /opt/dappnode/dappnode/. "$HERMES_HOME/skills/devops/dappnode/" + echo "[dappnode] Seeded DAppNode skill" +fi +if [ ! -d "$HERMES_HOME/skills/devops/dappnode-nexus" ] && [ -d /opt/dappnode/dappnode-nexus ]; then + as_hermes mkdir -p "$HERMES_HOME/skills/devops/dappnode-nexus" + as_hermes cp -r /opt/dappnode/dappnode-nexus/. "$HERMES_HOME/skills/devops/dappnode-nexus/" + echo "[dappnode] Seeded DAppNode Nexus skill" +fi + +# --- Patch config.yaml for DAppNode (LAN bind, ports, Nexus ctx length) ----- +# Run as hermes with the venv python so config.yaml stays hermes-owned. +if [ -f "$HERMES_HOME/config.yaml" ]; then + as_hermes "$INSTALL_DIR/.venv/bin/python3" /opt/dappnode/patch-config.py \ + || echo "[dappnode] Warning: could not patch config.yaml, continuing with defaults" +fi diff --git a/rootfs/etc/s6-overlay/s6-rc.d/setup-wizard/dependencies.d/base b/rootfs/etc/s6-overlay/s6-rc.d/setup-wizard/dependencies.d/base new file mode 100644 index 0000000..e69de29 diff --git a/rootfs/etc/s6-overlay/s6-rc.d/setup-wizard/run b/rootfs/etc/s6-overlay/s6-rc.d/setup-wizard/run new file mode 100644 index 0000000..afc8ba3 --- /dev/null +++ b/rootfs/etc/s6-overlay/s6-rc.d/setup-wizard/run @@ -0,0 +1,18 @@ +#!/command/with-contenv sh +# shellcheck shell=sh +# DAppNode setup wizard (config UI on :8080). +# +# Runs as ROOT, deliberately. Its /api/restart endpoint does +# `process.kill(1, SIGTERM)` to restart the whole container so a freshly +# saved config.yaml is re-applied by the cont-init hooks. Under s6-overlay +# PID 1 is /init (root), so an unprivileged process can't signal it. The +# wizard only serves an admin UI on the DAppNode private network and shells +# out to the `hermes` CLI via s6-setuidgid (see server.cjs), so the hermes +# runtime itself never runs as root. +export HOME=/opt/data +export HERMES_HOME="${HERMES_HOME:-/opt/data}" +# Expose the venv so the wizard can find `hermes` (it drops to the hermes +# user via s6-setuidgid for the actual CLI call). +export PATH="/opt/hermes/.venv/bin:$PATH" +cd /opt/data +exec node /opt/setup-wizard/server.cjs diff --git a/rootfs/etc/s6-overlay/s6-rc.d/setup-wizard/type b/rootfs/etc/s6-overlay/s6-rc.d/setup-wizard/type new file mode 100644 index 0000000..5883cff --- /dev/null +++ b/rootfs/etc/s6-overlay/s6-rc.d/setup-wizard/type @@ -0,0 +1 @@ +longrun diff --git a/rootfs/etc/s6-overlay/s6-rc.d/ttyd/dependencies.d/base b/rootfs/etc/s6-overlay/s6-rc.d/ttyd/dependencies.d/base new file mode 100644 index 0000000..e69de29 diff --git a/rootfs/etc/s6-overlay/s6-rc.d/ttyd/run b/rootfs/etc/s6-overlay/s6-rc.d/ttyd/run new file mode 100644 index 0000000..edc47ea --- /dev/null +++ b/rootfs/etc/s6-overlay/s6-rc.d/ttyd/run @@ -0,0 +1,9 @@ +#!/command/with-contenv sh +# shellcheck shell=sh +# DAppNode web terminal (ttyd on :7681), running as the hermes user. +# `bash -l` sources /etc/profile.d/hermes-venv.sh (installed by +# 10-dappnode-setup), which puts the venv on PATH and cd's to HERMES_HOME. +export HOME=/opt/data +export TERM="${TERM:-xterm-256color}" +cd /opt/data +exec s6-setuidgid hermes ttyd -p 7681 -W bash -l diff --git a/rootfs/etc/s6-overlay/s6-rc.d/ttyd/type b/rootfs/etc/s6-overlay/s6-rc.d/ttyd/type new file mode 100644 index 0000000..5883cff --- /dev/null +++ b/rootfs/etc/s6-overlay/s6-rc.d/ttyd/type @@ -0,0 +1 @@ +longrun diff --git a/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/setup-wizard b/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/setup-wizard new file mode 100644 index 0000000..e69de29 diff --git a/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/ttyd b/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/ttyd new file mode 100644 index 0000000..e69de29 diff --git a/setup-wizard/index.html b/setup-wizard/index.html index e2786df..9626ee1 100644 --- a/setup-wizard/index.html +++ b/setup-wizard/index.html @@ -1,5 +1,6 @@ + @@ -20,161 +21,531 @@ --error: #f87171; --radius: 12px; } - * { margin: 0; padding: 0; box-sizing: border-box; } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: var(--bg); color: var(--text); min-height: 100vh; + background: var(--bg); + color: var(--text); + min-height: 100vh; } /* ---- Top Nav ---- */ .topnav { - display: flex; align-items: center; gap: 2rem; - border-bottom: 1px solid var(--border); padding: 0 2rem; + display: flex; + align-items: center; + gap: 2rem; + border-bottom: 1px solid var(--border); + padding: 0 2rem; background: var(--card); } + .topnav .logo { - font-weight: 700; font-size: 1.1rem; padding: 1rem 0; + font-weight: 700; + font-size: 1.1rem; + padding: 1rem 0; background: linear-gradient(135deg, var(--primary), var(--accent)); - -webkit-background-clip: text; -webkit-text-fill-color: transparent; - background-clip: text; white-space: nowrap; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + white-space: nowrap; } - .topnav .tabs { display: flex; gap: 0; margin-left: 1rem; } + + .topnav .tabs { + display: flex; + gap: 0; + margin-left: 1rem; + } + .topnav .tab { - padding: 1rem 1.25rem; font-size: 0.9rem; color: var(--text-muted); - cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; + padding: 1rem 1.25rem; + font-size: 0.9rem; + color: var(--text-muted); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; user-select: none; } - .topnav .tab:hover { color: var(--text); } - .topnav .tab.active { color: var(--primary); border-bottom-color: var(--primary); } + + .topnav .tab:hover { + color: var(--text); + } + + .topnav .tab.active { + color: var(--primary); + border-bottom-color: var(--primary); + } /* ---- Tab Content ---- */ - .tab-content { display: none; } - .tab-content.active { display: block; } - .page { max-width: 720px; margin: 0 auto; padding: 2rem 1rem; } + .tab-content { + display: none; + } + + .tab-content.active { + display: block; + } + + .page { + max-width: 720px; + margin: 0 auto; + padding: 2rem 1rem; + } /* ---- Dashboard ---- */ - .status-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 1.5rem; } - @media (max-width: 540px) { .status-grid { grid-template-columns: 1fr; } } + .status-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 1.5rem; + } + + @media (max-width: 540px) { + .status-grid { + grid-template-columns: 1fr; + } + } + .status-card { - background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); padding: 1rem 1.25rem; } - .status-card .label { color: var(--text-muted); font-size: 0.8rem; margin-bottom: 4px; } - .status-card .value { font-size: 1.05rem; font-weight: 600; } - .status-card .value.ok { color: var(--success); } - .status-card .value.err { color: var(--error); } - .status-card .value.warn { color: var(--warning); } - .quick-links { display: flex; flex-direction: column; gap: 10px; margin-top: 1.5rem; } + + .status-card .label { + color: var(--text-muted); + font-size: 0.8rem; + margin-bottom: 4px; + } + + .status-card .value { + font-size: 1.05rem; + font-weight: 600; + } + + .status-card .value.ok { + color: var(--success); + } + + .status-card .value.err { + color: var(--error); + } + + .status-card .value.warn { + color: var(--warning); + } + + .quick-links { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 1.5rem; + } + .quick-links a { - display: block; padding: 12px 16px; background: var(--card); - border: 1px solid var(--border); border-radius: 8px; color: var(--text); - text-decoration: none; font-size: 0.9rem; transition: border 0.2s; + display: block; + padding: 12px 16px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + text-decoration: none; + font-size: 0.9rem; + transition: border 0.2s; + } + + .quick-links a:hover { + border-color: var(--primary); + } + + .quick-links a small { + color: var(--text-muted); + display: block; + margin-top: 2px; + font-size: 0.8rem; } - .quick-links a:hover { border-color: var(--primary); } - .quick-links a small { color: var(--text-muted); display: block; margin-top: 2px; font-size: 0.8rem; } /* ---- Setup Wizard ---- */ - .progress { display: flex; gap: 6px; margin-bottom: 2rem; } - .progress .bar { flex: 1; height: 4px; border-radius: 2px; background: var(--border); transition: background 0.3s; } - .progress .bar.active { background: var(--primary); } - .progress .bar.done { background: var(--success); } - .step { display: none; } - .step.active { display: block; } - .step h2 { font-size: 1.35rem; margin-bottom: 0.5rem; } - .step .subtitle { color: var(--text-muted); margin-bottom: 1.5rem; font-size: 0.95rem; line-height: 1.5; } - .cards { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 1.5rem; } - @media (max-width: 480px) { .cards { grid-template-columns: 1fr; } } + .progress { + display: flex; + gap: 6px; + margin-bottom: 2rem; + } + + .progress .bar { + flex: 1; + height: 4px; + border-radius: 2px; + background: var(--border); + transition: background 0.3s; + } + + .progress .bar.active { + background: var(--primary); + } + + .progress .bar.done { + background: var(--success); + } + + .step { + display: none; + } + + .step.active { + display: block; + } + + .step h2 { + font-size: 1.35rem; + margin-bottom: 0.5rem; + } + + .step .subtitle { + color: var(--text-muted); + margin-bottom: 1.5rem; + font-size: 0.95rem; + line-height: 1.5; + } + + .cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 1.5rem; + } + + @media (max-width: 480px) { + .cards { + grid-template-columns: 1fr; + } + } + .card { - background: var(--card); border: 2px solid var(--border); border-radius: var(--radius); - padding: 1rem; cursor: pointer; transition: all 0.2s; + background: var(--card); + border: 2px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + cursor: pointer; + transition: all 0.2s; + } + + .card:hover { + background: var(--card-hover); + border-color: var(--primary); + } + + .card.selected { + border-color: var(--primary); + background: var(--card-hover); } - .card:hover { background: var(--card-hover); border-color: var(--primary); } - .card.selected { border-color: var(--primary); background: var(--card-hover); } - .card .card-title { font-weight: 600; font-size: 0.95rem; margin-bottom: 4px; } - .card .card-desc { color: var(--text-muted); font-size: 0.8rem; line-height: 1.4; } + + .card .card-title { + font-weight: 600; + font-size: 0.95rem; + margin-bottom: 4px; + } + + .card .card-desc { + color: var(--text-muted); + font-size: 0.8rem; + line-height: 1.4; + } + .card .card-tag { - display: inline-block; font-size: 0.7rem; padding: 2px 8px; border-radius: 99px; - margin-top: 6px; background: rgba(255,215,0,0.15); color: var(--primary); + display: inline-block; + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 99px; + margin-top: 6px; + background: rgba(255, 215, 0, 0.15); + color: var(--primary); + } + + .card .card-tag.free { + background: rgba(52, 211, 153, 0.15); + color: var(--success); } - .card .card-tag.free { background: rgba(52,211,153,0.15); color: var(--success); } + .card.featured { grid-column: 1 / -1; - background: linear-gradient(135deg, rgba(255,215,0,0.08), rgba(255,215,0,0.02)); + background: linear-gradient(135deg, rgba(255, 215, 0, 0.08), rgba(255, 215, 0, 0.02)); border-color: var(--primary); position: relative; padding: 1.25rem 1.25rem 1.25rem 1.25rem; } - .card.featured:hover { background: linear-gradient(135deg, rgba(255,215,0,0.14), rgba(255,215,0,0.04)); } - .card.featured.selected { background: linear-gradient(135deg, rgba(255,215,0,0.18), rgba(255,215,0,0.06)); } - .card.featured .card-title { font-size: 1.1rem; margin-bottom: 6px; } - .card.featured .card-desc { font-size: 0.9rem; } + + .card.featured:hover { + background: linear-gradient(135deg, rgba(255, 215, 0, 0.14), rgba(255, 215, 0, 0.04)); + } + + .card.featured.selected { + background: linear-gradient(135deg, rgba(255, 215, 0, 0.18), rgba(255, 215, 0, 0.06)); + } + + .card.featured .card-title { + font-size: 1.1rem; + margin-bottom: 6px; + } + + .card.featured .card-desc { + font-size: 0.9rem; + } + .card.featured .card-tag { - position: absolute; top: 10px; right: 12px; margin-top: 0; - background: var(--primary); color: #1a1a1a; font-weight: 600; - } - .field { margin-bottom: 1.25rem; } - .field label { display: block; font-size: 0.9rem; font-weight: 500; margin-bottom: 6px; } - .field .hint { color: var(--text-muted); font-size: 0.8rem; margin-bottom: 8px; line-height: 1.4; } - .field input, .field select { - width: 100%; padding: 10px 14px; background: var(--card); - border: 1px solid var(--border); border-radius: 8px; color: var(--text); - font-size: 0.95rem; outline: none; transition: border 0.2s; - } - .field input:focus, .field select:focus { border-color: var(--primary); } - .field input::placeholder { color: var(--text-muted); } - .field select option { background: var(--card); color: var(--text); } - .field .optional-badge { font-size: 0.75rem; color: var(--text-muted); font-weight: 400; margin-left: 6px; } - .model-input-wrap { position: relative; } - .model-input-wrap input { padding-right: 40px; } + position: absolute; + top: 10px; + right: 12px; + margin-top: 0; + background: var(--primary); + color: #1a1a1a; + font-weight: 600; + } + + .field { + margin-bottom: 1.25rem; + } + + .field label { + display: block; + font-size: 0.9rem; + font-weight: 500; + margin-bottom: 6px; + } + + .field .hint { + color: var(--text-muted); + font-size: 0.8rem; + margin-bottom: 8px; + line-height: 1.4; + } + + .field input, + .field select { + width: 100%; + padding: 10px 14px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 0.95rem; + outline: none; + transition: border 0.2s; + } + + .field input:focus, + .field select:focus { + border-color: var(--primary); + } + + .field input::placeholder { + color: var(--text-muted); + } + + .field select option { + background: var(--card); + color: var(--text); + } + + .field .optional-badge { + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 400; + margin-left: 6px; + } + + .model-input-wrap { + position: relative; + } + + .model-input-wrap input { + padding-right: 40px; + } + .model-suggestions { - display: none; position: absolute; top: 100%; left: 0; right: 0; z-index: 10; - max-height: 220px; overflow-y: auto; background: var(--card); - border: 1px solid var(--border); border-radius: 0 0 8px 8px; - box-shadow: 0 8px 24px rgba(0,0,0,0.4); + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 10; + max-height: 220px; + overflow-y: auto; + background: var(--card); + border: 1px solid var(--border); + border-radius: 0 0 8px 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); } - .model-suggestions.open { display: block; } + + .model-suggestions.open { + display: block; + } + .model-suggestions .sg-item { - padding: 8px 14px; cursor: pointer; font-size: 0.85rem; - border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; - } - .model-suggestions .sg-item:hover { background: var(--card-hover); } - .model-suggestions .sg-item .sg-id { color: var(--text); } - .model-suggestions .sg-item .sg-ctx { color: var(--text-muted); font-size: 0.75rem; } - .model-suggestions .sg-loading { padding: 12px 14px; color: var(--text-muted); font-size: 0.85rem; } - .actions { display: flex; gap: 12px; margin-top: 2rem; } - .btn { padding: 12px 28px; border: none; border-radius: 8px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s; } - .btn-primary { background: linear-gradient(135deg, var(--primary), var(--accent)); color: white; flex: 1; } - .btn-primary:hover { opacity: 0.9; transform: translateY(-1px); } - .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } - .btn-secondary { background: var(--card); border: 1px solid var(--border); color: var(--text); } - .btn-secondary:hover { background: var(--card-hover); } + padding: 8px 14px; + cursor: pointer; + font-size: 0.85rem; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + } + + .model-suggestions .sg-item:hover { + background: var(--card-hover); + } + + .model-suggestions .sg-item .sg-id { + color: var(--text); + } + + .model-suggestions .sg-item .sg-ctx { + color: var(--text-muted); + font-size: 0.75rem; + } + + .model-suggestions .sg-loading { + padding: 12px 14px; + color: var(--text-muted); + font-size: 0.85rem; + } + + .actions { + display: flex; + gap: 12px; + margin-top: 2rem; + } + + .btn { + padding: 12px 28px; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + } + + .btn-primary { + background: linear-gradient(135deg, var(--primary), var(--accent)); + color: white; + flex: 1; + } + + .btn-primary:hover { + opacity: 0.9; + transform: translateY(-1px); + } + + .btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } + + .btn-secondary { + background: var(--card); + border: 1px solid var(--border); + color: var(--text); + } + + .btn-secondary:hover { + background: var(--card-hover); + } + .probe-btn { - display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; - background: var(--card); border: 1px solid var(--border); border-radius: 8px; - color: var(--text); font-size: 0.85rem; cursor: pointer; margin-bottom: 1rem; - } - .probe-btn:hover { border-color: var(--primary); } - .probe-result { padding: 10px 14px; border-radius: 8px; font-size: 0.85rem; margin-bottom: 1rem; line-height: 1.5; } - .probe-result.ok { background: rgba(52,211,153,0.1); border: 1px solid rgba(52,211,153,0.3); color: var(--success); } - .probe-result.fail { background: rgba(248,113,113,0.1); border: 1px solid rgba(248,113,113,0.3); color: var(--error); } + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 0.85rem; + cursor: pointer; + margin-bottom: 1rem; + } + + .probe-btn:hover { + border-color: var(--primary); + } + + .probe-result { + padding: 10px 14px; + border-radius: 8px; + font-size: 0.85rem; + margin-bottom: 1rem; + line-height: 1.5; + } + + .probe-result.ok { + background: rgba(52, 211, 153, 0.1); + border: 1px solid rgba(52, 211, 153, 0.3); + color: var(--success); + } + + .probe-result.fail { + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.3); + color: var(--error); + } + .config-preview { - background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); - padding: 1rem; font-family: 'SF Mono','Fira Code',monospace; font-size: 0.8rem; - line-height: 1.6; overflow-x: auto; max-height: 400px; overflow-y: auto; - white-space: pre; color: var(--text-muted); + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.8rem; + line-height: 1.6; + overflow-x: auto; + max-height: 400px; + overflow-y: auto; + white-space: pre; + color: var(--text-muted); + } + + .success-icon { + font-size: 3rem; + text-align: center; + margin-bottom: 1rem; } - .success-icon { font-size: 3rem; text-align: center; margin-bottom: 1rem; } /* ---- Terminal ---- */ - .terminal-wrap { display: flex; flex-direction: column; height: calc(100vh - 53px); } - .terminal-wrap iframe { flex: 1; border: none; background: #000; } + .terminal-wrap { + display: flex; + flex-direction: column; + height: calc(100vh - 53px); + } + + .terminal-wrap iframe { + flex: 1; + border: none; + background: #000; + } + .terminal-hint { - padding: 8px 2rem; background: var(--card); border-top: 1px solid var(--border); - color: var(--text-muted); font-size: 0.8rem; + padding: 8px 2rem; + background: var(--card); + border-top: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.8rem; + } + + .terminal-hint code { + background: var(--bg); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.8rem; } - .terminal-hint code { background: var(--bg); padding: 2px 6px; border-radius: 4px; font-size: 0.8rem; } +