Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion dappnode/dappnode-nexus/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,52 @@ 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

- 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

### Context length defaults to 256K with Nexus provider

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

Expand Down
55 changes: 44 additions & 11 deletions dappnode/patch-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ver>`` 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:
Expand All @@ -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.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
"""
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 = {}
Expand Down Expand Up @@ -159,18 +192,18 @@ 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", {})
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:
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
Expand Down
5 changes: 3 additions & 2 deletions dappnode_package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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."
}
Expand Down
54 changes: 52 additions & 2 deletions setup-wizard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,28 @@ <h2 style="text-align:center">Configuration Saved!</h2>
// =========================================================================
// 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" },
Expand Down Expand Up @@ -1065,7 +1087,35 @@ <h2>Configure DAppNode Nexus</h2>
<div class="hint">Models are fetched live from Nexus. Browse all at <a href="https://nexus.dappnode.com/models" target="_blank" style="color:var(--primary)">nexus.dappnode.com/models</a>.</div>
<div id="nexus-model-container"><div class="model-suggestions sg-loading" style="display:block;position:static">Loading models from Nexus...</div></div>
</div>
<div class="field">
<label>Private mode</label>
<div class="hint">Sends prompts through the attested local proxy instead of straight to Nexus. Requires the <strong>nexus-local-proxy</strong> package installed and running on this DAppNode.</div>
<label style="display:flex;align-items:center;gap:8px;font-weight:normal;margin-top:6px;">
<input type="checkbox" id="nexus-attested" style="width:auto;">
Route through the attested Nexus proxy on this DAppNode
</label>
<div id="nexus-attested-notice"
style="display:none; margin-top:10px; padding:10px 12px; background:rgba(255,215,0,0.12); border:1px solid var(--primary); border-radius:8px; font-size:0.85rem; line-height:1.5;">
<strong style="color:var(--primary);">&rarr; What this changes</strong><br>
Without it, your prompts travel to Nexus over ordinary HTTPS, which is decrypted at
Cloudflare before it reaches the Gateway. With it, the <strong>nexus-local-proxy</strong>
package on this DAppNode verifies that the Gateway is the expected code running inside an
AWS Nitro Enclave, and encrypts prompts and completions so Cloudflare cannot read them.
<br><br>
<strong style="color:var(--primary);">&rarr; The trade-off</strong><br>
This package does not install the proxy for you &mdash; install
<strong>nexus-local-proxy</strong> first, or leave this off. The proxy also
<strong>fails closed</strong>: if it cannot verify the Gateway it refuses to run,
and Hermes will report connection errors until it recovers &mdash; there is no silent
fallback to the unprotected path. Check
<a href="${NEXUS_PROXY_VERIFICATION_URL}" target="_blank" style="color:var(--primary)">the verification page</a>
to see the current verdict and the evidence behind it.
</div>
</div>
`;
document.getElementById("nexus-attested").addEventListener("change", (e) => {
document.getElementById("nexus-attested-notice").style.display = e.target.checked ? "block" : "none";
});
fetchNexusModels();
return;
}
Expand Down Expand Up @@ -1361,7 +1411,7 @@ <h2>Configure ${p.name}</h2>
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") {
Expand Down Expand Up @@ -1413,7 +1463,7 @@ <h2>Configure ${p.name}</h2>
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
Expand Down