diff --git a/dappnode/dappnode/SKILL.md b/dappnode/dappnode/SKILL.md index d955e43..897b497 100644 --- a/dappnode/dappnode/SKILL.md +++ b/dappnode/dappnode/SKILL.md @@ -70,6 +70,20 @@ 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` +#### Verified TEE mode (optional) +Nexus also runs a Gateway inside a trusted execution environment. In that mode prompts +go to the **Nexus Local Proxy** package on this DAppNode instead of directly to the Nexus +API. That proxy verifies the Gateway is running the exact software DAppNode published, +inside genuine sealed hardware, before any prompt leaves the machine, and encrypts prompt +and reply bodies to that enclave. If verification fails it refuses to carry traffic. + +Requires the **Nexus Local Proxy** package to be installed. Enable it with the "Verified +TEE mode" toggle in the Setup Wizard, which switches the base URL to +`http://nexus-local-proxy.dappnode.private:3301/v1`. The API key and model ID are +unchanged. Check status at `http://nexus-local-proxy.dappnode.private:3301/verification`. + +Hermes speaks plain OpenAI HTTP either way; nothing else about the configuration differs. + ## Troubleshooting ### Package Not Reachable diff --git a/dappnode_package.json b/dappnode_package.json index dbf6664..19504a5 100644 --- a/dappnode_package.json +++ b/dappnode_package.json @@ -1,7 +1,7 @@ { "name": "hermes-agent.dnp.dappnode.eth", - "version": "0.1.0", - "upstreamVersion": "v2026.4.23", + "version": "0.1.2", + "upstreamVersion": "v2026.4.30", "upstreamRepo": "NousResearch/hermes-agent", "upstreamArg": "UPSTREAM_VERSION", "shortDescription": "Self-improving AI agent with multi-LLM support and messaging gateway", diff --git a/docker-compose.yml b/docker-compose.yml index e7145c6..26efa8a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: context: . dockerfile: Dockerfile args: - UPSTREAM_VERSION: v2026.4.23 + UPSTREAM_VERSION: v2026.4.30 image: hermes-agent.dnp.dappnode.eth:0.1.0 container_name: DAppNodePackage-hermes-agent.dnp.dappnode.eth restart: unless-stopped @@ -15,8 +15,8 @@ services: NODE_ENV: production API_SERVER_ENABLED: "true" API_SERVER_PORT: "3000" - API_SERVER_HOST: "0.0.0.0" - API_SERVER_KEY: "dappnode" + API_SERVER_HOST: 0.0.0.0 + API_SERVER_KEY: dappnode API_SERVER_CORS_ORIGINS: "*" GATEWAY_ALLOW_ALL_USERS: "true" volumes: diff --git a/setup-wizard/index.html b/setup-wizard/index.html index ba28dfa..40de303 100644 --- a/setup-wizard/index.html +++ b/setup-wizard/index.html @@ -398,6 +398,7 @@

Configuration Saved!

const raw = data.config || ""; const provMatch = raw.match(/provider:\s*["']?([^"'\n]+)/); const modelMatch = raw.match(/default:\s*["']?([^"'\n]+)/); + nexusTeeEnabled = (env.OPENAI_BASE_URL || "").startsWith("http://nexus-local-proxy.dappnode.private:3301"); document.getElementById("st-provider").textContent = provMatch ? provMatch[1].trim() : (env.LLM_MODEL ? "custom" : "—"); document.getElementById("st-model").textContent = modelMatch ? modelMatch[1].trim() : (env.LLM_MODEL || "—"); } catch {} @@ -553,12 +554,42 @@

Configure DAppNode Nexus

Sign up at nexus.dappnode.com and create an API key from your dashboard.
+
+ +
+ Sends prompts through the Nexus Local Proxy on this DAppNode instead of straight to the Nexus API. + The proxy checks the Gateway is running inside genuine sealed hardware, running the exact software DAppNode published, before + anything you type leaves this machine — and encrypts prompts and replies so nothing in between can read them. + If that check fails, nothing is sent. +
+ + +
Browse the available models at nexus.dappnode.com/models and paste the full provider/model ID below.
- +
`; + document.getElementById("nexus-model-container") + .appendChild(createModelInput("nexus-model", [], "e.g. minimax/minimax-m2.7")); + const teeToggle = document.getElementById("nexus-tee-enabled"); + teeToggle.checked = nexusTeeEnabled; + teeToggle.addEventListener("change", (e) => { + nexusTeeEnabled = e.target.checked; + document.getElementById("nexus-tee-panel").style.display = e.target.checked ? "block" : "none"; + if (e.target.checked) probeNexus(); + }); + document.getElementById("nexus-tee-panel").style.display = nexusTeeEnabled ? "block" : "none"; + if (nexusTeeEnabled) probeNexus(); return; } @@ -695,6 +726,46 @@

Configure ${p.name}

} } + // Whether prompts go through the local verifying proxy. Restored from the + // saved base URL so reopening the wizard does not silently turn it off. + let nexusTeeEnabled = false; + const NEXUS_TEE_BASE_URL = "http://nexus-local-proxy.dappnode.private:3301/v1"; + const NEXUS_STANDARD_BASE_URL = "https://nexus-api.dappnode.com/v1"; + + function nexusBaseUrl() { + return nexusTeeEnabled ? NEXUS_TEE_BASE_URL : NEXUS_STANDARD_BASE_URL; + } + + async function probeNexus() { + const btn = document.getElementById("nexus-probe-btn"); + const icon = document.getElementById("nexus-probe-icon"); + const resultEl = document.getElementById("nexus-probe-result"); + if (!btn || !resultEl) return; + btn.disabled = true; icon.textContent = "\u23F3"; resultEl.innerHTML = ""; + try { + const resp = await fetch("/api/nexus/probe"); + const data = await resp.json(); + if (data.reachable && data.verified) { + const rev = data.sourceRevision ? data.sourceRevision.slice(0, 12) : "unknown"; + resultEl.innerHTML = `
Gateway verified. The local proxy checked ${data.checks || 0} things and confirmed the Gateway is running DAppNode release ${rev} inside sealed hardware.
Your prompts will be encrypted to that enclave.
`; + if (data.models && data.models.length) { + const mc = document.getElementById("nexus-model-container"); + const previous = (document.getElementById("nexus-model-input") || {}).value || ""; + mc.innerHTML = ""; + mc.appendChild(createModelInput("nexus-model", data.models, "Select or type a model...")); + document.getElementById("nexus-model-input").value = previous || data.models[0]; + } + } else if (data.reachable) { + resultEl.innerHTML = `
Proxy running, but not verified. ${data.reason || "Status: " + (data.status || "unknown")}.
It will not carry prompts until it can verify the Gateway. Leave TEE mode off until this reads verified.
`; + } else { + resultEl.innerHTML = `
Nexus Local Proxy not found. Install the Nexus Local Proxy package on this DAppNode, then check again. Without it, TEE mode cannot be used.
`; + } + } catch { + resultEl.innerHTML = `
Could not check the local proxy.
`; + } + btn.disabled = false; icon.textContent = "\uD83D\uDD0D"; + } + async function probeOllama() { const btn = document.getElementById("probe-btn"); const icon = document.getElementById("probe-icon"); @@ -738,7 +809,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() || "minimax/minimax-m2.7"; } else if (p.id === "ollama") { @@ -789,7 +860,7 @@

Configure ${p.name}

if (p.id === "nexus") { lines.push(` default: "${getModelValue() || "minimax/minimax-m2.7"}"`); lines.push(` provider: "custom"`); - lines.push(` base_url: "https://nexus-api.dappnode.com/v1"`); + lines.push(` base_url: "${nexusBaseUrl()}"`); } else if (p.id === "ollama") { const url = (document.getElementById("ollama-url").value || "").trim() || "http://ollama.dappnode:11434"; lines.push(` default: "${getModelValue() || "llama3"}"`); diff --git a/setup-wizard/server.cjs b/setup-wizard/server.cjs index 343e877..c301e14 100644 --- a/setup-wizard/server.cjs +++ b/setup-wizard/server.cjs @@ -12,6 +12,14 @@ const CONFIG_FILE = path.join(HERMES_HOME, "config.yaml"); const ENV_FILE = path.join(HERMES_HOME, ".env"); const HTML_FILE = path.join(__dirname, "index.html"); +// The Nexus Local Proxy is the only thing on a DAppNode that verifies the +// Nexus Gateway's enclave attestation and encrypts prompt bodies to it. Hermes +// never speaks that protocol itself: it points at the proxy and lets it do the +// verifying, which is why TEE mode is a base-URL switch and nothing more. +const NEXUS_TEE_BASE_URL = "http://nexus-local-proxy.dappnode.private:3301/v1"; +const NEXUS_STANDARD_BASE_URL = "https://nexus-api.dappnode.com/v1"; +const NEXUS_TEE_VERIFICATION_URL = "http://nexus-local-proxy.dappnode.private:3301/v1/verification"; + const OLLAMA_CANDIDATES = [ "http://ollama.ollama-nvidia-openwebui.dappnode:11434", "http://ollama.ollama-amd-openwebui.dappnode:11434", @@ -93,6 +101,46 @@ function readEnv() { catch { return {}; } } +// probeNexusProxy reports whether the local proxy is installed and has +// verified the Gateway. It reads the proxy's own verification state rather +// than attesting anything here: one verifier on the node, and everything else +// reads its answer. +async function probeNexusProxy() { + try { + const resp = await fetch(NEXUS_TEE_VERIFICATION_URL, { signal: AbortSignal.timeout(5000) }); + if (!resp.ok) { + return { reachable: true, verified: false, reason: `verification endpoint returned HTTP ${resp.status}` }; + } + const data = await resp.json(); + const current = data.current || {}; + return { + reachable: true, + verified: data.status === "verified", + status: data.status || "unknown", + gateway: data.gateway || null, + sourceRevision: current.source_revision || null, + checks: Array.isArray(current.checks) ? current.checks.length : 0, + models: await fetchNexusModels(), + }; + } catch { + return { reachable: false, verified: false, reason: "not installed or not running" }; + } +} + +// The proxy passes the Gateway's public model catalog through, so the wizard +// can offer real model IDs instead of asking the user to copy one from a web +// page. A failure here is not fatal: the model field stays free-text. +async function fetchNexusModels() { + try { + const resp = await fetch(`${NEXUS_TEE_BASE_URL}/models`, { signal: AbortSignal.timeout(5000) }); + if (!resp.ok) return []; + const data = await resp.json(); + return (data.data || []).map((m) => m.id).filter(Boolean).sort(); + } catch { + return []; + } +} + async function probeOllama() { for (const url of OLLAMA_CANDIDATES) { try { @@ -201,6 +249,13 @@ const server = http.createServer(async (req, res) => { return; } + // Probe the Nexus Local Proxy + if (req.method === "GET" && url.pathname === "/api/nexus/probe") { + const result = await probeNexusProxy(); + json(res, 200, result); + return; + } + // Probe Ollama if (req.method === "GET" && url.pathname === "/api/ollama/probe") { const result = await probeOllama();