Skip to content

hermes: skip the auth bootstrap when the credential is unchanged (three Python boots off every wake) - #86

Open
tonychang04 wants to merge 2 commits into
mainfrom
fix/hermes-skip-auth-bootstrap-when-unchanged
Open

hermes: skip the auth bootstrap when the credential is unchanged (three Python boots off every wake)#86
tonychang04 wants to merge 2 commits into
mainfrom
fix/hermes-skip-auth-bootstrap-when-unchanged

Conversation

@tonychang04

@tonychang04 tonychang04 commented Sep 3, 2026

Copy link
Copy Markdown
Member

What

The hermes entrypoint skips the dashboard auth bootstrap (hash the password, hermes config set twice) when the supplied credential already matches what config.yaml holds. That bootstrap is three full Python starts of the hermes CLI before the dashboard can listen, on every wake.

Why

Measured on InstaCloud prod (2026-09-02): the plane's share of a hermes wake is about 2 s; the app's own boot is about 12 s, and most of that is the entrypoint re-doing work whose result is already on the volume. The wake path is what tenants feel, and hermes is the slowest template after n8n.

How

Before the bootstrap, a stdlib-only Python check (yaml, hashlib, base64, hmac) reads config.yaml, requires the stored username to equal ADMIN_USERNAME, re-derives the stored scrypt$n$r$p$salt$digest hash from ADMIN_PASSWORD with the stored parameters (the same routine as upstream's _verify_password: dklen=len(digest), maxmem=0) and compares in constant time. A match skips straight to hermes gateway start (first boot only, unchanged) and the dashboard. Anything else takes the full path: first boot, a rotated secret, a missing or malformed hash, any Python error (the decision is the script's exit status, so nothing is masked by a pipeline). Nothing new is stored: the only secret material on the volume stays the salted scrypt hash the dashboard keeps anyway. Everything after the bootstrap is unchanged.

Proof (local Docker, nousresearch/hermes-agent:v2026.8.27, amd64 under emulation on a Mac, so read the deltas, not the absolutes)

boot entrypoint volume state ready in bootstrap skipped?
1 current fresh 35.1 s no
2, 3 current warm 26.9 s, 28.7 s no
4 this PR warm (config written by the current entrypoint) 21.4 s yes, from the first boot: it verifies against the hash already there
5, 6 this PR warm 22.5 s, 22.0 s yes
7 this PR rotated password slow path no (hash rewritten)
8 this PR matches the new password fast path yes

About 6 s off every warm-volume boot here; on the plane's 4 vCPU VM the three CLI starts are the same fraction of the ~12 s app boot.

Credentials after a fast-path boot, through the dashboard's real login endpoint POST /auth/password-login (JSON, provider basic): the current password is accepted ({"ok":true}), the previous one and a wrong one are rejected (401 Invalid credentials).

Review log

  • Round 1 (r2d2: changes requested): the first version kept a SHA-256 of user:password as a marker, a fast unsalted verifier weaker than the scrypt hash already stored. Replaced by verifying against that hash; no marker exists. The pipefail suggestion is moot in the same change: the fast path is gated on a Python exit status, not a pipeline.

Not in this PR

  • The gateway hermes gateway start seed and the s6 supervision model are untouched.
  • No change to the manifest or the image.

🤖 Generated with Claude Code

https://claude.ai/code/session_01G1fBttq231ZKpU5ivCAG2i

…ee Python boots off every wake)

Every boot hashed ADMIN_PASSWORD (a Python start that imports the hermes
plugin tree) and ran `hermes config set` twice (two more CLI boots)
before the dashboard could start -- on a 4 vCPU VM that is most of the
~12 s hermes takes to wake on InstaCloud, and the result was already in
config.yaml on the volume from the previous boot.

The entrypoint now keeps a marker next to config.yaml holding a SHA-256
of `user:password` (never the values; 0600, next to the bcrypt hash that
config.yaml already stores). A boot whose credential digest matches the
marker and still finds config.yaml skips straight to serving; anything
else -- first boot, a rotated secret, a missing or unreadable marker, a
config.yaml that went missing -- takes the full path, and the marker is
written last, only after both `config set` calls succeeded, so it can
never describe a config that was not written.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1fBttq231ZKpU5ivCAG2i

@jwfing jwfing left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary
This change should not merge as-is because the optimization adds a fast offline password verifier for the Hermes dashboard credential.

Requirements Context
I used the PR description as the main intent: skip the expensive Hermes auth bootstrap on warm boots when the admin credential is unchanged, while still taking the slow path for first boot, rotated credentials, missing config, or bad marker state. I did not find a linked issue in the provided metadata. Repo context confirms the dashboard is protected by an operator-chosen ADMIN_USERNAME/ADMIN_PASSWORD, state persists under HERMES_HOME=/data/.hermes, and the password is the primary gate in front of an agent holding API keys (templates/hermes/README.md:22-40, templates/hermes/README.md:49-80, templates/hermes/insta.template.yaml:27-39).

Findings

Critical

  • templates/hermes/entrypoint.sh:37-50 stores sha256(user:password) on the persistent volume. That marker is a fast, unsalted password verifier for the user-chosen dashboard password, while the existing config.yaml path stores a bcrypt hash. Anyone who can read a volume snapshot, backup, diagnostic bundle, or same-container filesystem can test password guesses cheaply by hashing username:guess; this materially weakens the auth secret and contradicts the claim that the marker “widens nothing.” Avoid persisting a fast password-derived digest; use a non-password-equivalent credential version/fingerprint if the platform exposes one, or keep the stored verifier at least as hard to attack as the existing bcrypt-based config.

Suggestion

  • templates/hermes/entrypoint.sh:38-50 should make digest computation fail closed. With set -e but no pipefail, a sha256sum failure can be masked by cut, producing an empty digest that may be written and later treated as a match. Validate a 64-hex digest before compare/write, or enable pipeline failure handling.
  • templates/hermes/entrypoint.sh:49-51 does not guarantee the marker is 0600 if the file already exists, because shell redirection preserves the existing file mode. It also resets the process umask to 022 rather than restoring the previous value before the dashboard process starts. Force the marker mode and restore the prior umask.
  • templates/hermes/entrypoint.sh:37-52 has no automated coverage for the new state machine. Existing template tests cover manifest/entrypoint variable consistency (templates/scripts/variables-lib.test.mjs:103-132), but not first boot, matching marker, rotated secret, missing config, empty marker, or unreadable marker behavior.

Information

  • templates/hermes/entrypoint.sh:39-46 otherwise matches the PR’s functionality/performance shape: it only skips when config.yaml exists and the marker matches, and it leaves the gateway/dashboard flow unchanged. No SQL, HTTP request handling, new dependencies, or request-time performance paths are introduced. git diff --check main...HEAD -- templates/hermes/entrypoint.sh passed; I did not run the full test suite because the review is read-only and the existing tests create/remove fixtures.

Verdict
Request changes due to the password-derived SHA-256 marker.

…of keeping a marker (review round 1)

r2d2: a SHA-256 of user:password on the volume is a fast, unsalted
verifier -- cheaper to attack than the scrypt hash config.yaml already
holds. So no marker at all: the entrypoint now checks the supplied
credential against config.yaml directly -- username equal, password
re-derived with the stored scrypt parameters (the same routine as
upstream's _verify_password: split on "$", base64 salt and digest,
hashlib.scrypt with dklen=len(digest), maxmem=0) and compared in
constant time. Stdlib Python only, a fraction of a second, and nothing
new is stored anywhere. Any failure of the check -- no config.yaml, no
hash, malformed, a Python error -- takes the full bootstrap path (exit
status, not a pipeline, so nothing can be masked), which also answers
the pipefail suggestion.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1fBttq231ZKpU5ivCAG2i

@jwfing jwfing left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary
The fast-path idea is sound, but this revision has release and correctness blockers before merge.

Requirements context
I used the PR description as the primary intent: skip the Hermes dashboard auth bootstrap only when config.yaml already holds the same ADMIN_USERNAME and a matching scrypt hash for ADMIN_PASSWORD; first boot, rotated credentials, missing/malformed hashes, and Python errors must take the slow path. Local template docs confirm Hermes persists state under /data/.hermes, requires operator-supplied dashboard credentials, and starts the dashboard from this entrypoint (templates/hermes/README.md:20-34, templates/hermes/README.md:47-65, templates/hermes/insta.template.yaml:26-39). I also checked the pinned upstream image reference (templates/hermes/Dockerfile:1-2) against upstream Hermes basic-auth behavior: https://github.com/NousResearch/hermes-agent/blob/v2026.8.27/plugins/dashboard_auth/basic/__init__.py#L123-L146.

Findings

Critical:

  • templates/hermes/entrypoint.sh:39-65, templates/hermes/insta.template.yaml:1-19, templates/scripts/version-guard.mjs:1-5, templates/scripts/version-guard.mjs:58-60 - The Hermes template runtime changed but the manifest still advertises version 2.3.1 and the image tag still points at templates/hermes:2.3.1. The repo explicitly requires a version bump for any non-doc publishable template change because otherwise the canonical GHCR tag is overwritten and existing deployments can drift. This should block until the Hermes manifest version and image tag are bumped.
  • templates/hermes/entrypoint.sh:49-64 - The fast-path verifier does not reject all malformed hashes. It uses stored.split("$", 5) and non-validating base64.b64decode, so a stored value with an extra delimiter such as a trailing $ can still decode to the same digest and return success here, while upstream Hermes rejects it because its verifier requires exactly six $-separated fields. In that case the entrypoint skips hermes config set, leaves the malformed hash in config.yaml, and the dashboard's real login verifier rejects the credential. This violates the stated requirement that malformed hashes take the slow path and regresses the old always-rewrite behavior.

Suggestion:

  • templates/hermes/entrypoint.sh:39-65, templates/scripts/variables-lib.test.mjs:104-124 - Add focused coverage for the new credential fast path, especially matching hash, rotated password, missing hash, malformed extra-field hash, and unreadable YAML. The existing template tests validate env declarations, but they do not exercise this shell/Python verifier behavior.

Information:

  • templates/hermes/entrypoint.sh:32-43 - The comment and PR text call the check stdlib-only, but it imports yaml from PyYAML. Hermes already relies on YAML support, so this is not a dependency blocker, just wording worth correcting.
  • templates/hermes/entrypoint.sh:57-64 - Security review found no new secret logging in the changed path; the skip log is non-sensitive and the slow path still suppresses the config set output that would echo the hash.
  • templates/hermes/entrypoint.sh:39-65 - Performance review found no separate concern with the intended warm path; the added check is bounded to one config read and one scrypt verification instead of three Hermes CLI startups.
  • .github/workflows/templates-lint.yml:29-35 - I did not run the local checks because dependencies are not installed in this read-only checkout, and installing them would modify the workspace.

Verdict
Request changes. The version bump is required by repo policy, and the verifier needs to match upstream's malformed-hash rejection before the fast path is safe.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="templates/hermes/entrypoint.sh">

<violation number="1" location="templates/hermes/entrypoint.sh:53">
P2: When persistent config contains a parseable but resource-intensive scrypt hash, this call performs unbounded work before the fallback can run, so startup can hang or be killed. Bound the scrypt parameters and derived-key length before invoking `hashlib.scrypt`.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

if kind != "scrypt":
sys.exit(1)
salt, digest = base64.b64decode(salt), base64.b64decode(digest)
got = hashlib.scrypt(os.environ["ADMIN_PASSWORD"].encode(), salt=salt, n=int(n), r=int(r), p=int(p), dklen=len(digest), maxmem=0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When persistent config contains a parseable but resource-intensive scrypt hash, this call performs unbounded work before the fallback can run, so startup can hang or be killed. Bound the scrypt parameters and derived-key length before invoking hashlib.scrypt.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At templates/hermes/entrypoint.sh, line 53:

<comment>When persistent config contains a parseable but resource-intensive scrypt hash, this call performs unbounded work before the fallback can run, so startup can hang or be killed. Bound the scrypt parameters and derived-key length before invoking `hashlib.scrypt`.</comment>

<file context>
@@ -25,30 +25,43 @@ mkdir -p "$HERMES_HOME"
+if kind != "scrypt":
+    sys.exit(1)
+salt, digest = base64.b64decode(salt), base64.b64decode(digest)
+got = hashlib.scrypt(os.environ["ADMIN_PASSWORD"].encode(), salt=salt, n=int(n), r=int(r), p=int(p), dklen=len(digest), maxmem=0)
+sys.exit(0 if hmac.compare_digest(got, digest) else 1)
+PY
</file context>

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