Skip to content

Make Nexus privacy mode a runtime switch - #48

Open
Marketen wants to merge 10 commits into
mainfrom
feat/nexus-tee-runtime-switch
Open

Make Nexus privacy mode a runtime switch#48
Marketen wants to merge 10 commits into
mainfrom
feat/nexus-tee-runtime-switch

Conversation

@Marketen

@Marketen Marketen commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Why

Hermes could already be pointed at the attested local proxy, but only while running the setup wizard — the choice was baked in at configuration time. Trying it meant walking back through provider selection and re-entering the Nexus API key.

Nothing about the two routes justifies that. Direct and private are the same account, the same key, the same provider (custom) and the same model ids. The only difference is model.base_url:

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 http://nexus-local-proxy.dappnode.private:3301/v1 Nobody between the proxy and the enclave

So this makes it a switch on the Dashboard tab that flips that one value and restarts Hermes to apply it. No key re-entry, no provider change.

What's here

  • dappnode/nexus_mode.py owns both endpoints and the direct/private distinction, and edits config.yaml with a real YAML parser rather than pattern-matching config text. patch-config.py now imports its constants so the boot-time patch and the runtime switch cannot drift apart.
  • GET/POST /api/nexus/mode in the wizard server read and flip the mode. The script runs as the hermes user, for the same reason hermes status does — a root-owned config.yaml leaves the gateway unable to write it.
  • The switch refuses to enable private mode while nexus-local-proxy is unreachable. The proxy fails closed, so without that check flipping it would take Hermes offline with a bare connection error instead of a message saying what to install.
  • When the proxy is up the card shows what is actually attested — verification status and the Gateway build being verified — not merely that a port is open.
  • Re-running the wizard now seeds its checkbox from the mode in force. It previously defaulted to off, so re-running it silently wrote the direct endpoint back and downgraded the user without saying so. That was a live bug on the previous branch.

Verification

Against the live TEE Gateway, with a real nexus-local-proxy running:

"proxy": { "reachable": true, "status": "verified",
           "sourceRevision": "893f4c9f306707b83f3b41782f25eb05adbc4f30",
           "verifiedTotal": 1 }
  • private → direct round trip leaves api_key, provider, default and context_length untouched
  • switching with the proxy down returns 409 with the reason, and does not modify config.yaml
  • a non-Nexus endpoint (Ollama) is refused rather than hijacked
  • missing config.yaml, missing model: section and a bad mode argument all fail cleanly
  • verified inside the built image: the venv Python runs the script and patch-config.py still imports it

Note on dependencies

This does not add a nexus-local-proxy package dependency. That package is not published onchain yet (dappnode/DAppNodePackage-nexus-local-proxy#16 renumbers it to 0.1.0 for its first publish), so declaring a hard dependency would break installs. The switch probes for the proxy at runtime and tells the user to install it instead. The dependency can be added once the package is on the registry.

🤖 Generated with Claude Code

Marketen and others added 4 commits August 24, 2026 12:36
…l 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Hermes could already be pointed at the attested local proxy, but only while
running the setup wizard: the choice was baked in at configuration time, so
trying it meant walking back through provider selection and re-entering the
Nexus API key.

Nothing about the two routes justifies that. Direct and private are the same
account, the same key, the same provider and the same model ids -- the only
difference is model.base_url. So this makes it a switch on the Dashboard tab
that flips exactly that one value and restarts Hermes to apply it.

- dappnode/nexus_mode.py owns both endpoints and the direct/private
  distinction, and edits config.yaml with a real YAML parser rather than
  pattern-matching config text. patch-config.py now imports the constants from
  it so the boot-time patch and the runtime switch cannot drift apart.
- GET/POST /api/nexus/mode in the wizard server read and flip the mode. The
  script runs as the hermes user, for the same reason `hermes status` does: a
  root-owned config.yaml would leave the gateway unable to write it.
- The switch refuses to enable private mode while nexus-local-proxy is
  unreachable. The proxy fails closed, so without that check flipping it would
  take Hermes offline with a bare connection error instead of a message
  explaining what to install.
- When the proxy is up, the card shows what is actually attested -- the
  verification status and the Gateway build being verified -- rather than only
  that a port is open.
- Re-running the wizard now seeds its checkbox from the mode in force. It
  previously defaulted to off, so re-running it would silently write the direct
  endpoint back and downgrade the user without saying so.

Verified against the live TEE Gateway: the probe reports status "verified" for
Gateway build 893f4c9, and a private/direct round trip leaves api_key,
provider, default and context_length untouched. Also verified inside the built
image, where the venv Python runs the script and patch-config.py still imports
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread dappnode/nexus_mode.py Fixed
@tropibot

tropibot Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Dappnode bot has built and pinned the built packages to an IPFS node, for commit: 73f1b5d

This is a development version and should only be installed for testing purposes.

  1. Package hermes-agent.dnp.dappnode.eth

Install link

Hash: /ipfs/QmUD4nF1n8upcqfFs8HYYN9eeGMXKGahPVN7gsfNVjD6iA

(by dappnodebot/build-action)

CodeQL flagged the substring match, and it is worse here than the generic
rule suggests. `NEXUS_PROXY_HOST in base_url` is true for

    http://nexus-local-proxy.dappnode.private.evil.com:3301/v1

so that endpoint would be reported as private mode and the dashboard would
tell the user their prompts were encrypted to an attested enclave while they
went somewhere else entirely. A privacy indicator that can be spoofed into
claiming a guarantee it cannot back is worse than no indicator.

Parse the URL and compare the hostname exactly. A base_url written without a
scheme still resolves, so nothing that used to be recognised stops being
recognised; the comparison is exact either way.

Verified against the spoofing cases the substring version accepted: the marker
appearing as a subdomain prefix, in a path, in a query string and in a
fragment are all now correctly not_nexus, while both real endpoints, a
trailing slash and a schemeless host still classify correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tropibot

tropibot Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

❌ Package harness failed

MCP tool 'dappnode_update_package' failed: Error: dockerComposeUpPackage: composePath /usr/src/app/dnp_repo/hermes-agent.dnp.dappnode.eth/docker-compose.yml is already being processed.

Package Verdict Reason Duration Cleanup
hermes-agent.dnp.dappnode.eth ❌ Failed Candidate install failed (candidate_install_failed) 37m 22s ✅ Passed

Run overview

Context Value
Pull request dappnode/DAppNodePackage-Hermes-agent#48
Tested commit 94acf52bc35d
Candidate artifact Install on DAppNode · /ipfs/QmV16YTZNoV5L8gLGLvzP4Quaz3aDBBCP9QnWdAuCRRRqX
Baseline request automatic
Worker worker-01
Run ID gh-pr-48-94acf52bc35d-a043acb976622880
Execution 2026-09-09T10:43:07.874738753+00:002026-09-09T11:20:30.870664383+00:00 (✅ Completed)

Baseline vs candidate

Check Baseline Candidate
Version 0.1.8 not reported
Install ✅ Passed · 1m 43s ❌ Failed · 0s
Hard check ✅ Passed · 5 stable samples ❌ Failed · 0 stable samples
Containers 1 observed 0 observed
Stabilization 22s 0s
Log collection ✅ Passed · 1 container ❌ Failed · 0 containers

Hard-check reasons

  • Baseline: none
  • Candidate: candidate_containers_unstable

Container comparison

  • Added: none
  • Removed: none
  • Baseline inventory: none
  • Candidate inventory: none
  • Baseline non-running states: none
  • Candidate non-running states: none
  • Deterministic regressions: ✅ none
Container inventory

Baseline

Container Service State Image Created
DAppNodePackage-hermes-agent.hermes-agent.dnp.dappnode.eth hermes-agent ✅ Running hermes-agent.hermes-agent.dnp.dappnode.eth:0.1.8 1788950691

Candidate

No container snapshot was available.

Runtime analysis advisory

⚪ Inconclusive — Log analysis was unavailable

Analyzer Status Summary
Heuristic ⚪ Inconclusive Log analysis was unavailable
Baseline evidence ⚪ Inconclusive Analysis unavailable
Candidate evidence ⚪ Inconclusive Analysis unavailable

No new runtime findings were reported.

Analyzer diagnostics
  • run ended before comparative log analysis

Cleanup and diagnostics

Check Result
Cleanup ✅ Passed
Leftover packages none
Cleanup error none
Phase Code Message
Candidate install candidate_install_failed MCP tool 'dappnode_update_package' failed: Error: dockerComposeUpPackage: composePath /usr/src/app/dnp_repo/hermes-agent.dnp.dappnode.eth/docker-compose.yml is already being processed.

Deterministic checks decide the verdict. Runtime log analysis is advisory. TropiBot publishes bounded summaries and never posts raw logs.

Marketen and others added 5 commits September 9, 2026 13:41
The proxy package moved to dappnode/DNP_NEXUS_PROXY and took the core naming
convention with it, so its internal DNS name changed:

  nexus-local-proxy.dappnode.private  ->  nexus-proxy.dappnode.private

Hermes hardcodes that host in the switch, the reachability probe, the manifest
link and the skill docs, so all of them move with it.

A config written before the rename still names the old host, which no longer
resolves, so private mode would fail forever with no indication of why. Two
things handle that: the old host is still recognised as private mode, since
the user did choose private and the report should stay honest, and the boot
patch repoints it to the new host before anything else reads base_url.

The rename does not weaken the hostname check: matching stays exact, so
nexus-proxy.dappnode.private.evil.com is still not_nexus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…issing

The private-mode copy was written for someone who already knows what a TLS
terminator and an attestation are. It explained the mechanism instead of the
promise, and it was long enough that nobody would read it.

Say the promise instead, briefly:

  On  — only the TEE can read your prompts
  Off — your prompts can be read in transit

Three things replace the old wall of text. "TEE (trusted execution
environment)" instead of "AWS Nitro Enclave" -- the vendor and the hardware
are not what a user is deciding about. "We cannot read them, and neither can
anyone in between" instead of naming which intermediary terminates TLS. And
"the proxy verifies it for you automatically, and you can check the proof
yourself" instead of a paragraph about failing closed. The exact mechanism is
still in the source comments, where the audience is maintainers.

The unreachable-proxy message now links to the Dappstore install page rather
than telling the user to go find a package by name. nexus_mode.py owns that
URL alongside the other endpoints, so it reaches the UI through the same
state object and cannot drift.

Also: Dappnode, not DAppNode, throughout the prose. Identifiers keep their
existing spelling, since DAppNodePackage- and DAppNodeCore- are real names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "off" copy read as a warning about the state the user is already in and
did nothing about: your prompts can be read, and here is the bad news. Nobody
chose that state, so warning them about it is just nagging. Off now describes
what is available rather than what is wrong:

  Off — available on this Dappnode
  Turn this on to encrypt your prompts so only the TEE running Nexus can
  read them. Same API key, same models, same prices.

The wizard hint leads with "Optional." for the same reason.

Private mode does cost something, so both the card and the wizard now say what.
Checked against the live TEE Gateway rather than assumed:

  Auto Router (nexus/auto)  production 200 (routed)  TEE 500
  normal models             200                      200
  private/* models          —                        200
  streaming                 —                        200
  catalog                   15 models                15 models (identical)

Auto Router fails on the direct TEE endpoint too, so it is the TEE deployment
rather than the proxy -- but private mode is the only way to reach that
Gateway, so the effect on the user is the same and the caveat belongs here.

PII masking is the second one, and comes from the signed egress manifest: the
enclave may only dial its measured routes, and the Presidio analyzer is not
among them, so masking cannot happen inside the TEE. Not confirmed against a
masking-enabled key, which I did not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Do not work in private mode yet" promises a fix nobody has committed to.
Neither Auto Router nor PII masking has one, so the caveat states the fact and
stops there.

Also rewrites the SKILL.md section the agent reads back to users. It carried
the framing the UI has already dropped — a table column headed "who can read
the prompt in transit", Cloudflare named as the reader, "silent downgrade to
the unprotected path". Hermes would have repeated all of that in conversation
while the UI said something else. It now says what private mode gives you, in
about half the words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Private mode protects the prompt from the proxy to the Gateway. A private/
model carries it 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. That is the combination worth recommending, and nothing said so.

The card, the wizard and the skill now say it. The model picker also badges
them, because a recommendation the user has to act on is more useful next to
the list than buried in prose.

The badge reads the catalog's proof_mode rather than the private/ prefix. The
prefix is a naming convention; labelling a model "private" because of its name
would be a claim we cannot back if the two ever diverge. The wizard's model
mapper dropped proof_mode, so it now passes it through as `attested`. The
prefix survives only as a fallback for the static offline suggestion list.

Verified against the live catalog: 5 of 15 models carry attested transport,
and the badge matches that set exactly.

Deliberately says private/ and not the provider's name in user-facing copy --
the provider may change, the id prefix is the stable thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tropibot

tropibot Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

🛠️ Package harness hit an infrastructure error

MCP tool 'dappnode_fetch_install_preview' failed: Error: All IPFS gateways failed (http://ipfs.dappnode:8080: 504 Gateway Timeout)

Package Verdict Reason Duration Cleanup
hermes-agent.dnp.dappnode.eth 🛠️ Infrastructure error Candidate install failed (candidate_install_failed) 2m 41s ✅ Passed

Run overview

Context Value
Pull request dappnode/DAppNodePackage-Hermes-agent#48
Tested commit 73f1b5d8c0e7
Candidate artifact Install on DAppNode · /ipfs/QmUD4nF1n8upcqfFs8HYYN9eeGMXKGahPVN7gsfNVjD6iA
Baseline request automatic
Worker worker-01
Run ID gh-pr-48-73f1b5d8c0e7-fc8d39eabf482c22
Execution 2026-09-09T13:06:57.980391869+00:002026-09-09T13:09:39.135624819+00:00 (✅ Completed)

Baseline vs candidate

Check Baseline Candidate
Version 0.1.8 not reported
Install ✅ Passed · 1m 38s ❌ Failed · 0s
Hard check ✅ Passed · 5 stable samples ❌ Failed · 0 stable samples
Containers 1 observed 0 observed
Stabilization 22s 0s
Log collection ✅ Passed · 1 container ❌ Failed · 0 containers

Hard-check reasons

  • Baseline: none
  • Candidate: candidate_containers_unstable

Container comparison

  • Added: none
  • Removed: none
  • Baseline inventory: none
  • Candidate inventory: none
  • Baseline non-running states: none
  • Candidate non-running states: none
  • Deterministic regressions: ✅ none
Container inventory

Baseline

Container Service State Image Created
DAppNodePackage-hermes-agent.hermes-agent.dnp.dappnode.eth hermes-agent ✅ Running hermes-agent.hermes-agent.dnp.dappnode.eth:0.1.8 1788959317

Candidate

No container snapshot was available.

Runtime analysis advisory

⚪ Inconclusive — Log analysis was unavailable

Analyzer Status Summary
Heuristic ⚪ Inconclusive Log analysis was unavailable
Baseline evidence ⚪ Inconclusive Analysis unavailable
Candidate evidence ⚪ Inconclusive Analysis unavailable

No new runtime findings were reported.

Analyzer diagnostics
  • run ended before comparative log analysis

Cleanup and diagnostics

Check Result
Cleanup ✅ Passed
Leftover packages none
Cleanup error none
Phase Code Message
Candidate preview candidate_install_failed MCP tool 'dappnode_fetch_install_preview' failed: Error: All IPFS gateways failed (http://ipfs.dappnode:8080: 504 Gateway Timeout)

Deterministic checks decide the verdict. Runtime log analysis is advisory. TropiBot publishes bounded summaries and never posts raw logs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants