From cca08b6451596595f8bfbd83345e40fe89dbd139 Mon Sep 17 00:00:00 2001 From: Marketen Date: Mon, 24 Aug 2026 11:50:10 +0200 Subject: [PATCH 01/10] Add a Private mode toggle that routes Nexus through the attested local proxy The wizard hardcoded the direct Nexus endpoint, which is the path where TLS terminates at Cloudflare and prompts are readable there. A checkbox on the Nexus configuration screen now switches model.base_url to the nexus-local-proxy package on the same DAppNode, which verifies the Gateway's AWS Nitro attestation against a pinned policy and encrypts bodies with EHBP past that point. It is a toggle in this package rather than a separate package: a fork would double maintenance and split users. The base URL is written in two places -- env.OPENAI_BASE_URL and model.base_url in the generated config.yaml -- and both now go through one nexusBaseUrl() helper so they cannot drift apart. config.yaml is the load-bearing one; the env vars get cleared by config migration on boot. Default is off. The proxy fails closed, so opting in should be a deliberate choice made after reading what it changes rather than something a user discovers when inference stops working. The toggle copy states that trade-off rather than presenting it as free -- a verification failure surfaces as connection errors with no silent fallback to the unprotected path -- and links to the proxy's verification page. No manifest dependency is declared. nexus-local-proxy has never been published to the DAppNode registry: resolving it by name returns NOREPO and it does not appear in a registry search, because only its IPFS artifacts exist and it has been installed by hash. A dependencies entry would therefore fail to resolve at install time and break installing Hermes at all. The toggle copy carries the requirement instead -- it names the package, says this one will not install it, and tells the user to leave the toggle off until they have. Two supporting fixes in patch-config.py: - The context_size lookup was gated on the literal string nexus-api.dappnode.com in base_url. With Private mode on that gate is false, so model.context_length would never be set and every model would silently use Hermes' 256K fallback -- wrong for all of them (Deepseek V4 Flash is 1048576, MiniMax M2.7 is 204800). It now recognises either Nexus route, and falls back to the public catalog when the configured endpoint has no /models, which is the case on nexus-local-proxy releases before 0.1.1. - That lookup has in fact never worked. Cloudflare fronts nexus-api.dappnode.com and 403s the default Python-urllib User-Agent, so the fetch always failed and every Nexus user has been running on the 256K fallback. Upstream Hermes already guards against the same WAF behaviour in providers/base.py. Sending a real User-Agent fixes it; verified live, returning 1048576 and 204800 for the two models above, both directly and through a running proxy. Verified: wizard JS syntax-checked, and buildEnv()/buildConfigYaml() exercised against a DOM stub to confirm both write sites flip together with the toggle while the API key and context_length still land in config.yaml. Co-Authored-By: Claude Opus 5 --- dappnode/dappnode-nexus/SKILL.md | 27 +++++++++++++++- dappnode/patch-config.py | 55 +++++++++++++++++++++++++------- dappnode_package.json | 5 +-- setup-wizard/index.html | 54 +++++++++++++++++++++++++++++-- 4 files changed, 125 insertions(+), 16 deletions(-) diff --git a/dappnode/dappnode-nexus/SKILL.md b/dappnode/dappnode-nexus/SKILL.md index a8b0e96..6868856 100644 --- a/dappnode/dappnode-nexus/SKILL.md +++ b/dappnode/dappnode-nexus/SKILL.md @@ -27,12 +27,34 @@ 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` +There are two ways to reach the API, chosen by the "Private mode" toggle in the +setup wizard: + +| Route | `model.base_url` | Who can read the prompt in transit | +|---|---|---| +| Direct | `https://nexus-api.dappnode.com/v1` | TLS terminates at Cloudflare, so prompts are visible there | +| Private mode | `http://nexus-local-proxy.dappnode.private:3301/v1` | Nobody between the proxy and the enclave | + +Private mode routes through the **nexus-local-proxy** package on the same +DAppNode. That proxy verifies the Nexus Gateway's AWS Nitro Enclave attestation +against a pinned trust policy and encrypts request and response bodies with +EHBP, so an intermediary that terminates TLS cannot read them. + +It **fails closed**: if the Gateway cannot be verified the proxy refuses to +run, and Hermes gets connection errors rather than a silent downgrade to the +unprotected path. The verification page at +`http://nexus-local-proxy.dappnode.private:3301/verification` shows the current +verdict, the checks performed, and the raw attestation evidence for independent +re-checking. + ## Key URLs | Resource | URL | |----------|-----| | Nexus Web App | https://nexus.dappnode.com/ | | Nexus API | https://nexus-api.dappnode.com/v1 | +| Attested local proxy | http://nexus-local-proxy.dappnode.private:3301/v1 | +| Proxy verification page | http://nexus-local-proxy.dappnode.private:3301/verification | | DAppNode Main Site | https://dappnode.com/ | ## Privacy Guarantees @@ -40,6 +62,9 @@ Nexus runs as a service within the DAppNode ecosystem. Users access it via: - 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 +- With Private mode on, prompt and completion bodies are additionally encrypted + to a measured enclave, so the TLS terminator in front of the Gateway cannot + read them ## Pitfalls @@ -47,7 +72,7 @@ Nexus runs as a service within the DAppNode ecosystem. Users access it via: 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 +1. Neither `nexus-api.dappnode.com` nor the local proxy is in Hermes' `_URL_TO_PROVIDER` map → treated as an unknown custom endpoint 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 diff --git a/dappnode/patch-config.py b/dappnode/patch-config.py index 2513610..4fcd93e 100644 --- a/dappnode/patch-config.py +++ b/dappnode/patch-config.py @@ -19,15 +19,31 @@ skip_dashboard_auth = os.environ.get("DAPPNODE_SKIP_DASHBOARD_AUTH") == "1" -def fetch_nexus_context_size(base_url, model_id): - """Return the context_size Nexus reports for model_id, or None. +# Nexus is reachable either directly or through the attested local proxy. Both +# expose the same OpenAI-compatible catalog, and the model ids are identical. +NEXUS_DIRECT_BASE_URL = "https://nexus-api.dappnode.com/v1" +NEXUS_BASE_URL_MARKERS = ("nexus-api.dappnode.com", "nexus-local-proxy.dappnode.private") - Queries the OpenAI-compatible ``{base_url}/models`` listing, which Nexus - serves publicly with a ``context_size`` field per model. - """ + +def is_nexus_base_url(base_url): + return any(marker in base_url for marker in NEXUS_BASE_URL_MARKERS) + + +# Cloudflare fronts nexus-api.dappnode.com and 403s the default +# ``Python-urllib/`` User-Agent, so this fetch silently failed and every +# Nexus user fell back to Hermes' 256K default. Upstream Hermes guards against +# the same WAF behaviour in providers/base.py. Send a real UA. +CATALOG_USER_AGENT = "hermes-agent-dappnode/1.0" + + +def _context_size_from(base_url, model_id): + """Return the context_size the catalog at base_url reports, or None.""" url = base_url.rstrip("/") + "/models" try: - req = urllib.request.Request(url, headers={"Accept": "application/json"}) + req = urllib.request.Request( + url, + headers={"Accept": "application/json", "User-Agent": CATALOG_USER_AGENT}, + ) with urllib.request.urlopen(req, timeout=10) as resp: data = json.load(resp) except Exception: @@ -39,6 +55,23 @@ def fetch_nexus_context_size(base_url, model_id): return None +def fetch_nexus_context_size(base_url, model_id): + """Return the context_size Nexus reports for model_id, or None. + + Tries the configured endpoint first, then the public Nexus catalog. The + fallback matters when Hermes points at the local proxy: proxy releases + before 0.1.1 serve only chat completions and 404 on ``/models``, and the + catalog is public either way, so there is nothing private to lose by + asking the direct endpoint for it. + """ + size = _context_size_from(base_url, model_id) + if size: + return size + if base_url.rstrip("/") == NEXUS_DIRECT_BASE_URL: + return None + return _context_size_from(NEXUS_DIRECT_BASE_URL, model_id) + + def read_dashboard_password(username): try: values = {} @@ -159,10 +192,10 @@ def configure_dashboard_auth(config): print(msg) # --- 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: +# Neither Nexus endpoint is 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", {}) @@ -170,7 +203,7 @@ def configure_dashboard_auth(config): 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: +if provider == "custom" and is_nexus_base_url(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 diff --git a/dappnode_package.json b/dappnode_package.json index 523a0c4..29ae5fd 100644 --- a/dappnode_package.json +++ b/dappnode_package.json @@ -62,7 +62,8 @@ "homepage": "https://hermes-agent.nousresearch.com", "setup": "http://hermes-agent.dappnode:8080", "terminal": "http://hermes-agent.dappnode:7681", - "ui": "http://hermes-agent.dappnode:8080/dashboard" + "ui": "http://hermes-agent.dappnode:8080/dashboard", + "verification": "http://nexus-local-proxy.dappnode.private:3301/verification" }, "name": "hermes-agent.dnp.dappnode.eth", "repository": { @@ -81,7 +82,7 @@ "upstreamArg": "UPSTREAM_VERSION", "upstreamRepo": "NousResearch/hermes-agent", "upstreamVersion": "v2026.7.20", - "version": "0.1.7", + "version": "0.1.8", "warnings": { "onRemove": "Removing this package will delete all your Hermes Agent configuration, conversation history, skills, memories, and cached data. Make sure to create a backup first." } diff --git a/setup-wizard/index.html b/setup-wizard/index.html index 0e97b77..2422a4a 100644 --- a/setup-wizard/index.html +++ b/setup-wizard/index.html @@ -894,6 +894,28 @@

Configuration Saved!

// ========================================================================= // Provider definitions // ========================================================================= + + // Nexus can be reached two ways. The direct endpoint terminates TLS at + // Cloudflare, so prompts are readable there. The local proxy verifies the + // Gateway's AWS Nitro attestation and encrypts bodies end-to-end past that + // point, at the cost of a hard dependency on nexus-local-proxy running. + const NEXUS_DIRECT_BASE_URL = "https://nexus-api.dappnode.com/v1"; + const NEXUS_PROXY_BASE_URL = "http://nexus-local-proxy.dappnode.private:3301/v1"; + const NEXUS_PROXY_VERIFICATION_URL = "http://nexus-local-proxy.dappnode.private:3301/verification"; + + // Whether the private-proxy toggle is on. Read through a helper so a + // screen that has not rendered the checkbox yet still gets the default. + // Off by default: the proxy fails closed, so opting in should be a + // deliberate choice made after reading what it changes. + function nexusUseProxy() { + const el = document.getElementById("nexus-attested"); + return el ? el.checked : false; + } + + function nexusBaseUrl() { + return nexusUseProxy() ? NEXUS_PROXY_BASE_URL : NEXUS_DIRECT_BASE_URL; + } + const PROVIDERS = [ { id: "nexus", name: "DAppNode Nexus", desc: "Private AI models — your prompts are never logged or stored", tag: "Recommended", free: false, envKey: "NEXUS_API_KEY", provider: "custom" }, { id: "openrouter", name: "OpenRouter", desc: "Access 200+ models with one key", tag: "Flexible", free: false, envKey: "OPENROUTER_API_KEY", provider: "openrouter" }, @@ -1065,7 +1087,35 @@

Configure DAppNode Nexus

Models are fetched live from Nexus. Browse all at nexus.dappnode.com/models.
Loading models from Nexus...
+
+ +
Sends prompts through the attested local proxy instead of straight to Nexus. Requires the nexus-local-proxy package installed and running on this DAppNode.
+ + +
`; + document.getElementById("nexus-attested").addEventListener("change", (e) => { + document.getElementById("nexus-attested-notice").style.display = e.target.checked ? "block" : "none"; + }); fetchNexusModels(); return; } @@ -1361,7 +1411,7 @@

Configure ${p.name}

if (p.id === "nexus") { const apiKey = (document.getElementById("api-key").value || "").trim(); if (apiKey) env.NEXUS_API_KEY = apiKey; - env.OPENAI_BASE_URL = "https://nexus-api.dappnode.com/v1"; + env.OPENAI_BASE_URL = nexusBaseUrl(); env.OPENAI_API_KEY = apiKey; env.LLM_MODEL = getModelValue() || "deepseek/deepseek-v4-pro"; } else if (p.id === "ollama") { @@ -1413,7 +1463,7 @@

Configure ${p.name}

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"`); + lines.push(` base_url: "${nexusBaseUrl()}"`); // Hermes reads the key for a custom endpoint from model.api_key in // config.yaml (the documented source of truth). The NEXUS_API_KEY / // OPENAI_API_KEY env vars are not a reliable path: NEXUS_API_KEY is From 5467e0268aa9d9b45dfe2fdff64aa7a56c738e4e Mon Sep 17 00:00:00 2001 From: Marketen Date: Tue, 25 Aug 2026 21:16:46 +0200 Subject: [PATCH 02/10] Depend on nexus-local-proxy now that it is a core package The dependency came out earlier because nexus-local-proxy.dnp.dappnode.eth had no registry entry -- resolving it by name returned NOREPO, so declaring it would have broken installing Hermes at all. It is now shipped as a core package via DNP_CORE, so it is guaranteed present and the dependency resolves. That makes the toggle copy accurate again: it no longer tells the user to go install the proxy themselves, only what routing through it changes and that it fails closed. Requires nexus-local-proxy 0.2.0 and the DNP_CORE release that carries it. Co-Authored-By: Claude Opus 5 --- dappnode_package.json | 3 +++ setup-wizard/index.html | 6 ++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/dappnode_package.json b/dappnode_package.json index 29ae5fd..4196bab 100644 --- a/dappnode_package.json +++ b/dappnode_package.json @@ -18,6 +18,9 @@ "Developer tools", "Communications" ], + "dependencies": { + "nexus-local-proxy.dnp.dappnode.eth": "^0.2.0" + }, "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.", "exposable": [ { diff --git a/setup-wizard/index.html b/setup-wizard/index.html index 2422a4a..5365c44 100644 --- a/setup-wizard/index.html +++ b/setup-wizard/index.html @@ -1089,7 +1089,7 @@

Configure DAppNode Nexus

-
Sends prompts through the attested local proxy instead of straight to Nexus. Requires the nexus-local-proxy package installed and running on this DAppNode.
+
Sends prompts through the attested local proxy instead of straight to Nexus.
-
Sends prompts through the attested local proxy instead of straight to Nexus.
+
Sends prompts through the attested local proxy instead of straight to Nexus. Requires the nexus-local-proxy package installed and running on this DAppNode.
+ + +

Quick Links

-
Sends prompts through the attested local proxy instead of straight to Nexus. Requires the nexus-local-proxy package installed and running on this DAppNode.
+
Sends prompts through the attested local proxy instead of straight to Nexus. Requires the nexus-proxy package installed and running on this DAppNode.