diff --git a/.gitignore b/.gitignore index 06df315..6f5940c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ build_*/ # Node node_modules/ +# Python (dappnode/ is now an importable module) +__pycache__/ +*.pyc + # OS files .DS_Store Thumbs.db diff --git a/Dockerfile b/Dockerfile index ab7a352..90fe0ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ RUN cd /opt/hermes/scripts/whatsapp-bridge && npm install --omit=dev --no-audit # Copy setup wizard into the image COPY setup-wizard/ /opt/setup-wizard/ -# Copy DAppNode context files (seeded into HERMES_HOME on first boot) +# Copy Dappnode context files (seeded into HERMES_HOME on first boot) COPY dappnode/ /opt/dappnode/ # v2026.7.1 auto-starts OAuth when BasicAuthProvider is the only dashboard @@ -22,7 +22,7 @@ COPY dappnode/ /opt/dappnode/ # package moves to a release containing it. RUN python3 /opt/dappnode/backport-dashboard-auth.py -# DAppNode s6-overlay customizations: a cont-init bootstrap hook plus the +# 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 @@ -39,7 +39,7 @@ ENV HERMES_HOME=/opt/data # Expose API server, web UI, setup wizard, and web terminal ports EXPOSE 3000 8080 8081 7681 -# Health check for DAppNode monitoring +# Health check for Dappnode monitoring HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \ CMD curl -f http://localhost:3000/health || exit 1 diff --git a/README.md b/README.md index de4b137..fc11638 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # DAppNodePackage-Hermes-agent -[![DAppNode](https://img.shields.io/badge/DAppNode-Package-blue)](https://dappnode.io) +[![Dappnode](https://img.shields.io/badge/DAppNode-Package-blue)](https://dappnode.io) [![Upstream](https://img.shields.io/badge/Upstream-NousResearch%2Fhermes--agent-blueviolet)](https://github.com/NousResearch/hermes-agent) -DAppNode package for [Hermes Agent](https://hermes-agent.nousresearch.com/) by [Nous Research](https://nousresearch.com/) — a self-improving AI agent with multi-LLM support, messaging gateway, persistent memory, and skills system. +Dappnode package for [Hermes Agent](https://hermes-agent.nousresearch.com/) by [Nous Research](https://nousresearch.com/) — a self-improving AI agent with multi-LLM support, messaging gateway, persistent memory, and skills system. ## Features @@ -16,7 +16,7 @@ DAppNode package for [Hermes Agent](https://hermes-agent.nousresearch.com/) by [ ## Getting Started -1. Install the package from the DAppNode Package Store +1. Install the package from the Dappnode Package Store 2. Open the **Setup Wizard** at `http://hermes-agent.dappnode:8080` to configure your AI provider and API key 3. Open the **Gateway Web UI** at `http://hermes-agent.dappnode:3000` to start chatting @@ -31,9 +31,9 @@ npx @dappnode/dappnodesdk build - [Hermes Agent Documentation](https://hermes-agent.nousresearch.com/docs/) - [Nous Research](https://nousresearch.com/) - [Upstream Repository](https://github.com/NousResearch/hermes-agent) -- [DAppNode SDK](https://docs.dappnode.io/docs/dev/sdk/overview) +- [Dappnode SDK](https://docs.dappnode.io/docs/dev/sdk/overview) ## License -This DAppNode package wrapper is provided under the same license as DAppNode packages (Apache-2.0). +This Dappnode package wrapper is provided under the same license as Dappnode packages (Apache-2.0). Hermes Agent itself is licensed under [MIT](https://github.com/NousResearch/hermes-agent/blob/main/LICENSE). diff --git a/dappnode/bootstrap-env.py b/dappnode/bootstrap-env.py index 317b421..462f415 100644 --- a/dappnode/bootstrap-env.py +++ b/dappnode/bootstrap-env.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Repair DAppNode-specific Hermes .env settings before services start.""" +"""Repair Dappnode-specific Hermes .env settings before services start.""" from __future__ import annotations import os @@ -102,7 +102,7 @@ def repair_profile_env(profile_home: Path, *, is_default: bool) -> dict[str, str if not has_usable_secret(env.get("API_SERVER_KEY", "")): updates["API_SERVER_KEY"] = secrets.token_hex(32) else: - # DAppNode exposes a single API server on port 3000. Named profile + # Dappnode exposes a single API server on port 3000. Named profile # gateways can still run messaging/cron, but must not each bind 3000. if env.get("API_SERVER_ENABLED", "").strip().lower() not in {"false", "0", "no"}: updates["API_SERVER_ENABLED"] = "false" diff --git a/dappnode/dappnode-nexus/SKILL.md b/dappnode/dappnode-nexus/SKILL.md index a8b0e96..d63939a 100644 --- a/dappnode/dappnode-nexus/SKILL.md +++ b/dappnode/dappnode-nexus/SKILL.md @@ -1,6 +1,6 @@ --- 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. +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 @@ -10,9 +10,9 @@ tags: - openai-compatible --- -# DAppNode Nexus +# Dappnode Nexus -Nexus is DAppNode's **Private AI Gateway** — a unified, OpenAI-compatible API for accessing private and confidential AI models. +Nexus is Dappnode's **Private AI Gateway** — a unified, OpenAI-compatible API for accessing private and confidential AI models. ## Core Value Proposition @@ -23,23 +23,73 @@ Nexus is DAppNode's **Private AI Gateway** — a unified, OpenAI-compatible API ## Architecture -Nexus runs as a service within the DAppNode ecosystem. Users access it via: +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` +**Nexus privacy mode** is a switch on the setup wizard's Dashboard tab. It can +be flipped at any time, not only during setup. + +Turned on, prompts go through the **nexus-proxy** package on the same Dappnode, +which encrypts them so only the TEE (trusted execution environment) running +Nexus can read them. The proxy verifies that TEE automatically on every +connection; the user can check the proof at +`http://nexus-proxy.dappnode.private:3301/verification`. + +It changes **only** `model.base_url`: + +| Mode | `model.base_url` | +|---|---| +| Off | `https://nexus-api.dappnode.com/v1` | +| On | `http://nexus-proxy.dappnode.private:3301/v1` | + +The API key, provider (`custom`) and model are the same either way, so +switching never needs a key re-entered or the provider reconfigured. Hermes +reads `config.yaml` at startup, so the switch restarts the package itself. + +The switch will not turn private mode on while `nexus-proxy` is unreachable: +the proxy fails closed, so Hermes would just stop working. Point the user at +the Dappstore to install it. + +### Recommend a `private/` model + +Private mode protects the prompt from the proxy to the Nexus Gateway. A model +whose id starts with `private/` extends that the rest of the way: the Gateway +reaches those over an attested, encrypted transport that fails closed, so the +prompt is protected end to end. + +If the user turns private mode on while using another model, suggest switching +to a `private/` one. `GET /v1/models` marks them. + +### What does not work in private mode + +Verified against the live TEE Gateway, not assumed: + +- **Auto Router (`nexus/auto`)** returns 500 on the TEE Gateway while working + on production. Tell the user to pick a specific model. +- **PII masking** does not apply. The masking service sits outside the TEE and + the TEE may only reach its measured egress routes, which exclude it. + +Normal models, the `private/*` models and streaming all work, and both +endpoints serve the same catalog. + ## 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/ | +| Attested local proxy | http://nexus-proxy.dappnode.private:3301/v1 | +| Proxy verification page | http://nexus-proxy.dappnode.private:3301/verification | +| Dappnode Main Site | https://dappnode.com/ | ## Privacy Guarantees -- Inference runs on DAppNode infrastructure, not external cloud providers +- 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 the TEE, so nobody in between can read them ## Pitfalls @@ -47,11 +97,11 @@ 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 -> **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`. +> **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`. @@ -74,4 +124,4 @@ Common Nexus-proxied models and their context lengths: | `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. +> **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/dappnode/dappnode/SKILL.md b/dappnode/dappnode/SKILL.md index 8ef8ad1..4284d3d 100644 --- a/dappnode/dappnode/SKILL.md +++ b/dappnode/dappnode/SKILL.md @@ -1,12 +1,12 @@ --- name: dappnode description: > - DAppNode package operations — HTTPS exposure, port mapping, inter-package + Dappnode package operations — HTTPS exposure, port mapping, inter-package connectivity, Nexus provider setup, and troubleshooting. Use when the user asks about networking, exposing services, connecting - to other packages, or configuring AI providers on DAppNode. + to other packages, or configuring AI providers on Dappnode. version: 1.0.0 -author: DAppNode Association +author: Dappnode Association license: MIT metadata: hermes: @@ -15,22 +15,22 @@ metadata: related_skills: [webhook-subscriptions] --- -# DAppNode Package Operations +# Dappnode Package Operations -This Hermes Agent runs as a DAppNode package. This skill covers DAppNode-specific procedures. +This Hermes Agent runs as a Dappnode package. This skill covers Dappnode-specific procedures. ## Exposing a Service via HTTPS -By default, services are only reachable from inside the DAppNode network. To make a service publicly accessible: +By default, services are only reachable from inside the Dappnode network. To make a service publicly accessible: -1. Direct the user to open the DAppNode UI (`http://my.dappnode`), find the Hermes Agent package, and go to its **Network** tab. +1. Direct the user to open the Dappnode UI (`http://my.dappnode`), find the Hermes Agent package, and go to its **Network** tab. 2. They configure: - **Subdomain**: a name they choose (e.g., `hermes-api`) - **Port**: which container port to expose (8081 for dashboard, 3000 for API, etc.) - **Basic auth** (optional but recommended): username and password 3. The resulting public URL will be: `https://.` -This is powered by the `https.dnp.dappnode.eth` package — an Nginx reverse proxy with automatic TLS via DAppNode's dyndns wildcard certificates. +This is powered by the `https.dnp.dappnode.eth` package — an Nginx reverse proxy with automatic TLS via Dappnode's dyndns wildcard certificates. ### Security Notes - Always recommend basic auth when exposing the dashboard (port 8081) @@ -45,13 +45,13 @@ If the user needs a publicly reachable webhook URL: ## Port Mapping to Host -Users can map container ports directly to the host machine's network via the **Network** tab of the Hermes Agent package in the DAppNode UI (`http://my.dappnode`). +Users can map container ports directly to the host machine's network via the **Network** tab of the Hermes Agent package in the Dappnode UI (`http://my.dappnode`). This allows access from the local network without VPN — useful for LAN-only setups. ## Inter-Package Connectivity -All DAppNode packages share the `dncore_network` Docker bridge network. Packages are reachable via DNS aliases. +All Dappnode packages share the `dncore_network` Docker bridge network. Packages are reachable via DNS aliases. ### DNS Pattern - Mono-service packages: `.dappnode` @@ -63,8 +63,8 @@ All DAppNode packages share the `dncore_network` Docker bridge network. Packages curl -sf http://.dappnode:/ -o /dev/null && echo "reachable" || echo "unreachable" ``` -### Using DAppNode Nexus (Recommended) -DAppNode Nexus (`https://nexus.dappnode.com`) is DAppNode's own privacy-focused LLM gateway. It is OpenAI-compatible and prompts are never logged, stored, or used for training. Check the Nexus website for available models and pricing. +### Using Dappnode Nexus (Recommended) +Dappnode Nexus (`https://nexus.dappnode.com`) is Dappnode's own privacy-focused LLM gateway. It is OpenAI-compatible and prompts are never logged, stored, or used for training. Check the Nexus website for available models and pricing. To configure, use the Setup Wizard at `http://hermes-agent.dappnode:8080` and select Nexus as the provider. Or manually: 1. Sign up at `https://nexus.dappnode.com` and create an API key @@ -77,25 +77,25 @@ model: provider: "nexus" providers: nexus: - name: "DAppNode Nexus" + name: "Dappnode Nexus" base_url: "https://nexus-api.dappnode.com/v1" key_env: "NEXUS_API_KEY" default_model: "minimax/minimax-m2.7" api_mode: "chat_completions" ``` -Verify with `hermes doctor`; the resolved provider source should read `custom_provider:DAppNode Nexus`, not `no-key-required`. +Verify with `hermes doctor`; the resolved provider source should read `custom_provider:Dappnode Nexus`, not `no-key-required`. -**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. +**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 -- The target package may not be installed or may be stopped — direct the user to the DAppNode UI (`http://my.dappnode`) to check -- Hermes cannot install or manage other DAppNode packages +- The target package may not be installed or may be stopped — direct the user to the Dappnode UI (`http://my.dappnode`) to check +- Hermes cannot install or manage other Dappnode packages ### Configuration -- Environment variables: editable in the **Config** tab of the Hermes Agent package in the DAppNode UI +- Environment variables: editable in the **Config** tab of the Hermes Agent package in the Dappnode UI - Config file: `/opt/data/config.yaml` - API keys: `/opt/data/.env` -- Logs: viewable in the **Logs** tab of the Hermes Agent package in the DAppNode UI +- Logs: viewable in the **Logs** tab of the Hermes Agent package in the Dappnode UI diff --git a/dappnode/hermes.md b/dappnode/hermes.md index 6ababe6..c1ef0b8 100644 --- a/dappnode/hermes.md +++ b/dappnode/hermes.md @@ -1,10 +1,10 @@ -# DAppNode Environment +# Dappnode Environment -This Hermes Agent instance runs as a DAppNode package inside an isolated Docker container. +This Hermes Agent instance runs as a Dappnode package inside an isolated Docker container. ## Network Access -Users connected to the DAppNode (via VPN or WiFi) access services at: +Users connected to the Dappnode (via VPN or WiFi) access services at: | Service | URL | |-----------------|------------------------------------------| @@ -15,12 +15,12 @@ Users connected to the DAppNode (via VPN or WiFi) access services at: These services are important, dont kill them. -**IMPORTANT**: Since this instance runs inside a DAppNode package, `localhost` does not work for users. Always give URLs using the DAppNode Hermes namespace: `http://hermes-agent.dappnode:` (e.g., `http://hermes-agent.dappnode:3000` for the API). The user accesses these from their browser while connected to the DAppNode network. +**IMPORTANT**: Since this instance runs inside a Dappnode package, `localhost` does not work for users. Always give URLs using the Dappnode Hermes namespace: `http://hermes-agent.dappnode:` (e.g., `http://hermes-agent.dappnode:3000` for the API). The user accesses these from their browser while connected to the Dappnode network. -## DAppNode Admin +## Dappnode Admin -The user manages this package (environment variables, port mappings, logs, HTTPS exposure) via the DAppNode UI at `http://my.dappnode` — find the Hermes Agent package and use the Config, Network, and Logs tabs. +The user manages this package (environment variables, port mappings, logs, HTTPS exposure) via the Dappnode UI at `http://my.dappnode` — find the Hermes Agent package and use the Config, Network, and Logs tabs. ## External Access -Services are only reachable from inside the DAppNode network by default. The user can expose any port to the public internet with an HTTPS subdomain (and optional basic auth) via the DAppNode network tab. Load the `dappnode` skill for details on how to guide the user through this. +Services are only reachable from inside the Dappnode network by default. The user can expose any port to the public internet with an HTTPS subdomain (and optional basic auth) via the Dappnode network tab. Load the `dappnode` skill for details on how to guide the user through this. diff --git a/dappnode/nexus_mode.py b/dappnode/nexus_mode.py new file mode 100644 index 0000000..d38f048 --- /dev/null +++ b/dappnode/nexus_mode.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Read and flip which Nexus endpoint Hermes talks to. + +Nexus is reachable two ways, and the only difference between them is +``model.base_url`` in config.yaml: + + direct https://nexus-api.dappnode.com/v1 + TLS terminates at Cloudflare, so prompts are readable there. + + private http://nexus-proxy.dappnode.private:3301/v1 + The nexus-proxy package on this Dappnode verifies the + Gateway's AWS Nitro attestation and encrypts request and response + bodies with EHBP, so the TLS terminator cannot read them. + +Both endpoints serve the same OpenAI-compatible catalog under the same model +ids and accept the same Nexus API key, so switching is only ever a base_url +change: the provider, the key and the selected model all stay put. That is +what makes this safe to flip at runtime rather than only at setup. + +Used as a module by patch-config.py and as a CLI by the setup wizard: + + nexus_mode.py get -> {"mode": ..., "base_url": ..., ...} + nexus_mode.py set private -> flips config.yaml, prints the new state +""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from urllib.parse import urlsplit + +import yaml + +NEXUS_DIRECT_HOST = "nexus-api.dappnode.com" +NEXUS_PROXY_HOST = "nexus-proxy.dappnode.private" + +# The proxy package was called nexus-local-proxy before it took the core +# DNP_NEXUS_PROXY naming. A config written back then still names the old host, +# which no longer resolves. Recognising it keeps the reported mode honest -- +# the user did choose private -- and boot-time migration repoints it. +LEGACY_PROXY_HOSTS = ("nexus-local-proxy.dappnode.private",) +NEXUS_DIRECT_BASE_URL = f"https://{NEXUS_DIRECT_HOST}/v1" +NEXUS_PROXY_BASE_URL = f"http://{NEXUS_PROXY_HOST}:3301/v1" +NEXUS_PROXY_VERIFICATION_URL = f"http://{NEXUS_PROXY_HOST}:3301/verification" +# Dappstore page for the proxy package, the same URL the Dappnode installer +# uses. It resolves once the package is published onchain. +NEXUS_PROXY_DNP_NAME = "nexus-proxy.dnp.dappnode.eth" +NEXUS_PROXY_INSTALL_URL = f"http://my.dappnode/installer/dnp/{NEXUS_PROXY_DNP_NAME}" + +MODE_DIRECT = "direct" +MODE_PRIVATE = "private" +# The configured endpoint is not Nexus at all (Ollama, OpenRouter, ...), so +# there is no Nexus mode to report and nothing this module may rewrite. +MODE_NOT_NEXUS = "not_nexus" + +BASE_URL_FOR_MODE = { + MODE_DIRECT: NEXUS_DIRECT_BASE_URL, + MODE_PRIVATE: NEXUS_PROXY_BASE_URL, +} + + +def config_path() -> Path: + return Path(os.environ.get("HERMES_HOME", "/opt/data")) / "config.yaml" + + +def endpoint_host(base_url: str) -> str: + """Return the lowercased hostname of base_url, or "" if there isn't one. + + Modes are decided on an exact hostname, never on a substring. Substring + matching would classify https://nexus-api.dappnode.com.example.net/v1 as + Nexus, and worse, would let a host merely *containing* the proxy name be + reported as private mode -- so the dashboard would promise the prompt was + encrypted to an attested enclave while it went somewhere else entirely. + """ + try: + # A base_url written without a scheme still has a host worth reading; + # the "//" prefix makes urlsplit treat it as an authority rather than + # a path. The comparison below stays exact either way. + candidate = base_url if "//" in base_url else "//" + base_url.lstrip("/") + return (urlsplit(candidate).hostname or "").lower() + except ValueError: + return "" + + +def detect_mode(base_url: str) -> str: + host = endpoint_host(base_url) + if host == NEXUS_PROXY_HOST or host in LEGACY_PROXY_HOSTS: + return MODE_PRIVATE + if host == NEXUS_DIRECT_HOST: + return MODE_DIRECT + return MODE_NOT_NEXUS + + +def migrate_legacy_host(path: Path | None = None) -> bool: + """Repoint a config still naming the pre-rename proxy host. + + Returns True when it rewrote something. Safe to run on every boot: it only + touches a base_url whose host is a known legacy name. + """ + path = path or config_path() + config = load_config(path) + model = config.get("model") + if not isinstance(model, dict): + return False + if endpoint_host(str(model.get("base_url") or "")) not in LEGACY_PROXY_HOSTS: + return False + model["base_url"] = NEXUS_PROXY_BASE_URL + config["model"] = model + temporary = path.with_suffix(path.suffix + ".tmp") + with open(temporary, "w") as handle: + yaml.dump(config, handle, default_flow_style=False, sort_keys=False) + os.replace(temporary, path) + return True + + +def is_nexus_base_url(base_url: str) -> bool: + return detect_mode(base_url) != MODE_NOT_NEXUS + + +def load_config(path: Path) -> dict: + try: + with open(path) as handle: + return yaml.safe_load(handle) or {} + except FileNotFoundError: + return {} + except Exception: + return {} + + +def read_state(path: Path | None = None) -> dict: + """Return the current Nexus endpoint state. Never raises.""" + path = path or config_path() + config = load_config(path) + model = config.get("model") or {} + base_url = str(model.get("base_url") or "") + mode = detect_mode(base_url) + return { + "mode": mode, + "base_url": base_url, + "is_nexus": mode != MODE_NOT_NEXUS, + "model": model.get("default") or model.get("model") or "", + "provider": model.get("provider") or "", + "direct_base_url": NEXUS_DIRECT_BASE_URL, + "private_base_url": NEXUS_PROXY_BASE_URL, + "verification_url": NEXUS_PROXY_VERIFICATION_URL, + "install_url": NEXUS_PROXY_INSTALL_URL, + } + + +def set_mode(mode: str, path: Path | None = None) -> dict: + """Point model.base_url at the requested Nexus endpoint. + + Only base_url is touched. The API key, provider and selected model are + left exactly as they are, which is the whole point: flipping modes must + never look like reconfiguring the provider. + """ + if mode not in BASE_URL_FOR_MODE: + raise ValueError(f"unknown mode {mode!r}, want 'direct' or 'private'") + + path = path or config_path() + config = load_config(path) + model = config.get("model") + if not isinstance(model, dict): + raise ValueError("config.yaml has no model section to switch") + + current = str(model.get("base_url") or "") + if detect_mode(current) == MODE_NOT_NEXUS: + # Refuse to hijack a non-Nexus provider. Without this, toggling the + # switch while Hermes points at Ollama would silently repoint it at + # Nexus with whatever key happened to be in config. + raise ValueError( + "the configured endpoint is not Nexus; change provider in the " + "wizard before switching Nexus modes" + ) + + target = BASE_URL_FOR_MODE[mode] + if current == target: + return read_state(path) + + model["base_url"] = target + config["model"] = model + # Write through a temporary file in the same directory so an interrupted + # write cannot leave Hermes with a truncated config.yaml. + temporary = path.with_suffix(path.suffix + ".tmp") + with open(temporary, "w") as handle: + yaml.dump(config, handle, default_flow_style=False, sort_keys=False) + os.replace(temporary, path) + return read_state(path) + + +def main(argv: list[str]) -> int: + if len(argv) >= 1 and argv[0] == "get": + print(json.dumps(read_state())) + return 0 + if len(argv) == 2 and argv[0] == "set": + try: + print(json.dumps(set_mode(argv[1]))) + return 0 + except ValueError as error: + print(json.dumps({"error": str(error)})) + return 1 + print(json.dumps({"error": "usage: nexus_mode.py get | set "})) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/dappnode/patch-config.py b/dappnode/patch-config.py index 2513610..f9bcc78 100644 --- a/dappnode/patch-config.py +++ b/dappnode/patch-config.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Patch a freshly-seeded Hermes config.yaml for the DAppNode environment. +"""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 @@ -19,15 +19,32 @@ 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. +# The endpoints and the direct/private distinction live in nexus_mode so the +# boot-time patch and the runtime switch cannot drift apart. +from nexus_mode import ( # noqa: E402 + NEXUS_DIRECT_BASE_URL, + is_nexus_base_url, + migrate_legacy_host, +) - Queries the OpenAI-compatible ``{base_url}/models`` listing, which Nexus - serves publicly with a ``context_size`` field per model. - """ + +# 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 +56,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 = {} @@ -94,9 +128,9 @@ def configure_dashboard_auth(config): if password and has_config_password: return False - # If Hermes already has only a password hash but DAppNode has no saved + # If Hermes already has only a password hash but Dappnode has no saved # plaintext credential, the setup wizard cannot perform its auto-login - # handoff. Generate a new DAppNode-managed password and keep both files in + # handoff. Generate a new Dappnode-managed password and keep both files in # sync so users are not stranded at the raw dashboard login screen. password = password or secrets.token_urlsafe(24) @@ -124,7 +158,7 @@ def configure_dashboard_auth(config): except Exception: config = {} -# --- Network access: bind the gateway to the LAN on the DAppNode port --- +# --- Network access: bind the gateway to the LAN on the Dappnode port --- gw = config.setdefault("gateway", {}) gw["port"] = 3000 gw["bind"] = "lan" @@ -153,16 +187,24 @@ def configure_dashboard_auth(config): with open(config_path, "w") as f: yaml.dump(config, f, default_flow_style=False, sort_keys=False) dashboard_auth_status = "skipped" if skip_dashboard_auth else "basic" -msg = f"Patched config.yaml for DAppNode (api_port=3000, dashboard_auth={dashboard_auth_status}, whatsapp_bridge_port=3010)" +msg = f"Patched config.yaml for Dappnode (api_port=3000, dashboard_auth={dashboard_auth_status}, whatsapp_bridge_port=3010)" if generated_dashboard_auth: msg += "; dashboard credentials saved to /opt/data/dashboard-login.txt" print(msg) +# --- Nexus proxy rename: repoint configs written before DNP_NEXUS_PROXY --- +# The old nexus-local-proxy.dappnode.private host no longer resolves, so a +# config still naming it would leave private mode permanently failing. Rewrite +# it before the context-length lookup below reads base_url. +if migrate_legacy_host(config_path): + config = yaml.safe_load(open(config_path)) or {} + print("Nexus: repointed the proxy host to nexus-proxy.dappnode.private") + # --- 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 +212,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..9058085 100644 --- a/dappnode_package.json +++ b/dappnode_package.json @@ -2,7 +2,7 @@ "architectures": [ "linux/amd64" ], - "author": "DAppNode Association ", + "author": "Dappnode Association ", "backup": [ { "name": "hermes-data", @@ -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-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.9", "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/getting-started.md b/getting-started.md index 8b29950..4b2073f 100644 --- a/getting-started.md +++ b/getting-started.md @@ -1,6 +1,6 @@ # Hermes Agent -A **self-hosted AI agent** by [Nous Research](https://nousresearch.com) that runs on your DAppNode. Hermes learns from experience, remembers you across sessions, and connects to Telegram, Discord, Slack, and more — all from a single process. +A **self-hosted AI agent** by [Nous Research](https://nousresearch.com) that runs on your Dappnode. Hermes learns from experience, remembers you across sessions, and connects to Telegram, Discord, Slack, and more — all from a single process. ## Getting started diff --git a/rootfs/etc/cont-init.d/10-dappnode-setup b/rootfs/etc/cont-init.d/10-dappnode-setup index a71e2e7..5054de6 100644 --- a/rootfs/etc/cont-init.d/10-dappnode-setup +++ b/rootfs/etc/cont-init.d/10-dappnode-setup @@ -1,9 +1,9 @@ #!/command/with-contenv sh # shellcheck shell=sh -# DAppNode cont-init hook. Runs as root during s6-overlay stage 2, AFTER +# 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; +# 02-reconcile-profiles. We only do the genuinely Dappnode-specific bits here; # everything generic is already handled upstream. set -e @@ -21,7 +21,7 @@ as_hermes() { # 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) +# 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" @@ -34,29 +34,29 @@ PROFILE chmod 0644 /etc/profile.d/hermes-venv.sh fi -# --- Seed DAppNode context files (first boot only) -------------------------- +# --- 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" + 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" + 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" + echo "[dappnode] Seeded Dappnode Nexus skill" fi -# --- Repair .env for DAppNode defaults and older wizard output ------------- +# --- Repair .env for Dappnode defaults and older wizard output ------------- # Upstream now rejects weak API_SERVER_KEY values and refuses to start # WhatsApp when WHATSAPP_ENABLED=true but no QR pairing exists yet. as_hermes "$INSTALL_DIR/.venv/bin/python3" /opt/dappnode/bootstrap-env.py \ || echo "[dappnode] Warning: could not repair .env, continuing with defaults" -# --- Patch config.yaml for DAppNode (LAN bind, ports, Nexus ctx length) ----- +# --- 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 \ diff --git a/rootfs/etc/s6-overlay/s6-rc.d/setup-wizard/run b/rootfs/etc/s6-overlay/s6-rc.d/setup-wizard/run index afc8ba3..0cdf89e 100644 --- a/rootfs/etc/s6-overlay/s6-rc.d/setup-wizard/run +++ b/rootfs/etc/s6-overlay/s6-rc.d/setup-wizard/run @@ -1,12 +1,12 @@ #!/command/with-contenv sh # shellcheck shell=sh -# DAppNode setup wizard (config UI on :8080). +# 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 +# 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 diff --git a/rootfs/etc/s6-overlay/s6-rc.d/ttyd/run b/rootfs/etc/s6-overlay/s6-rc.d/ttyd/run index edc47ea..0b78584 100644 --- a/rootfs/etc/s6-overlay/s6-rc.d/ttyd/run +++ b/rootfs/etc/s6-overlay/s6-rc.d/ttyd/run @@ -1,6 +1,6 @@ #!/command/with-contenv sh # shellcheck shell=sh -# DAppNode web terminal (ttyd on :7681), running as the hermes user. +# 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 diff --git a/setup-wizard/index.html b/setup-wizard/index.html index 0e97b77..67f2676 100644 --- a/setup-wizard/index.html +++ b/setup-wizard/index.html @@ -4,7 +4,7 @@ - Hermes Agent — DAppNode + Hermes Agent — Dappnode @@ -641,6 +683,23 @@

Agent Overview

+ + +

Quick Links