Skip to content

fix(gateway): /model off the event loop; distribution allowlist enforced; hindsight env file 0600 - #75888

Merged
teknium1 merged 5 commits into
mainfrom
salvage/gateway-misc-fixes
Aug 1, 2026
Merged

fix(gateway): /model off the event loop; distribution allowlist enforced; hindsight env file 0600#75888
teknium1 merged 5 commits into
mainfrom
salvage/gateway-misc-fixes

Conversation

@teknium1

@teknium1 teknium1 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

salvage: gateway /model loop offload, distribution_owned enforcement, hindsight env perms

Combines three narrowed community fixes onto main (branch salvage/gateway-misc-fixes).

Fixes #74373


1. /model context-length resolution off the gateway event loop — credit @Drexuxux (#74155)

Cherry-picked as-authored: the sync resolve_display_context_length() (route
comparison + provider probe ladder of blocking requests calls) was invoked
directly inside the async /model handlers in gateway/slash_commands.py,
freezing the whole event loop (~2s measured, worse on cold caches). The PR adds
resolve_display_context_length_async() — a thin asyncio.to_thread wrapper
mirroring the existing get_model_context_length_async() /
should_clear_context_pin_async() siblings — and awaits it at both handlers.

Our follow-up commit:

  • Covered the missed path: enrich_model_switch_warnings_for_gateway()
    merge_preflight_compression_warning() still called the sync resolver inline
    at both gateway call sites; both now dispatch via asyncio.to_thread.
  • Dropped/replaced: the 4th test used inspect.getsource() on
    gateway.slash_commands (source-reading tests are banned by AGENTS.md).
    Replaced with behavioral tests in
    tests/gateway/test_model_command_context_offload.py that drive the real
    _handle_model_command with a mocked switch pipeline and assert (a) the
    resolver runs off the loop thread and (b) the warning enrichment is
    dispatched through a spied asyncio.to_thread. The other 3 behavioral tests
    from the PR are kept unchanged.

2. distribution_owned allowlist enforcement — credit @webtecnica (#74414, issue #74373)

Cherry-picked only the allowlist-enforcement commit (a329bac):
_copy_dist_payload() iterated every staged entry and never consulted the
manifest's distribution_owned, making the documented allowlist declaration-only.

Dropped: commit 1b82637 (credential_pool/gateway/electron token
write-through) — unrelated to the allowlist bug and introduces a token with no
reader; out of scope for this salvage.

Our follow-up commit fixed two defects in the cherry-picked filter:

  • Omitted-list narrowing: the cherry-picked code filtered through
    owned_paths(), which falls back to DEFAULT_DIST_OWNED — so distributions
    that omit distribution_owned (the common case) would silently stop copying
    undeclared-but-legitimate top-level payload. Restored the legacy contract:
    when the list is omitted, everything outside USER_OWNED_EXCLUDE is copied.
    Enforcement now applies only when an explicit allowlist is declared.
  • Path-aware matching: the top-level name comparison dropped documented
    nested entries like skills/research/ and cron/digest.json. Explicit
    allowlists are now resolved path-aware: nested entries select exactly that
    subtree/file (siblings under the same parent are not dragged along), and
    traversal segments (.., absolute paths) and USER_OWNED_EXCLUDE roots are
    rejected.
  • Regression tests for both (omitted-list legacy behavior, nested-path
    allowlist) on top of the PR's own tests.

On Fixes #74373: yes — the salvaged behavior resolves the issue. The core
defect (allowlist never consulted from the mutation path) is fixed; unlisted
entries are no longer installed or updated when a manifest declares
distribution_owned. The issue's secondary concern (target-only children
removed by wholesale directory replacement) is also addressed by the path-aware
semantics: in the issue's repro (distribution_owned: [SOUL.md]), skills/ is
no longer touched at all, so skills/local-only/ survives update; and authors
can now scope ownership to skills/research/ to keep sibling subtrees
user-owned. Wholesale replacement remains only for paths an author explicitly
declares as distribution-owned, which is the documented ownership boundary the
issue asked to make unambiguous.

3. Hindsight embedded profile env file permissions — credit @carrion256 (#74236)

Reimplemented as a minimal fix confined to plugins/memory/hindsight/
(authored by us with Co-authored-by: carrion256 <carrion256@proton.me>):
_materialize_embedded_profile_env() wrote the plaintext
HINDSIGHT_API_LLM_API_KEY via bare write_text(), leaving the file with
umask-derived (typically world-readable) permissions.

  • File is created/truncated via os.open(..., O_CREAT|O_TRUNC, 0o600); a
    pre-existing file is chmod'd to 0600 before the new secret bytes land.
  • Post-write validation on POSIX verifies 0600 (with one chmod retry) and
    raises PermissionError otherwise.
  • On validation failure the secret file is unlinked — no plaintext key is left
    behind with unverified permissions.
  • Perms regression tests in tests/plugins/memory/test_hindsight_env_perms.py
    (fresh write under a permissive umask, tightening an existing 0644 file,
    cleanup on validation failure).

Dropped from the PR: the core utils.py atomic-replace opt-out
(allow_copy_fallback=False) and the mkstemp/fsync/atomic-rename machinery
plus the daemon-restart metadata plumbing built on it — that changes a shared
core utility for a plugin-local concern and violates the plugin-boundary rule
(plugin fixes touch only plugins/ + tests/plugins/). The 0600 + cleanup
guarantee is achieved without it.


Verification

  • tests/hermes_cli/test_model_switch_context_offload.py,
    tests/gateway/test_model_command_context_offload.py,
    tests/gateway/test_model_command_async_offload.py,
    tests/gateway/test_model_switch_persistence.py,
    tests/hermes_cli/test_profile_distribution.py,
    tests/plugins/memory/288 passed.
  • ruff check clean on all touched files.
  • Branch rebased onto current origin/main before push.

Infographic

PR infographic

Drexuxux and others added 5 commits July 31, 2026 22:00
…loop

resolve_display_context_length() runs two blocking chains: the route
comparison in should_clear_context_pin() and the provider probe ladder in
get_model_context_length() (blocking requests calls to Anthropic /v1/models,
Copilot, Nous, Codex, GMI, Ollama, models.dev and OpenRouter).

The gateway message path already offloads both via
get_model_context_length_async() and should_clear_context_pin_async(), but
the /model slash-command handlers (_handle_model_command, _finish_switch)
called the sync helper directly, freezing the whole event loop for the
duration of the probe ladder - no messages processed on any platform, and
the Discord heartbeat timeouts that get_model_context_length_async() was
introduced to prevent.

Add resolve_display_context_length_async(), a thin asyncio.to_thread wrapper
mirroring the two existing *_async helpers (no logic duplication), and await
it at both handlers.
…ioral offload tests

Follow-ups to the previous commit (#74155 by @Drexuxux):

- enrich_model_switch_warnings_for_gateway() -> merge_preflight_compression_warning()
  still called the sync resolve_display_context_length() provider probe ladder
  inline in both async /model call sites; dispatch it via asyncio.to_thread.
- Replace the inspect.getsource() test (source-reading tests are banned by
  AGENTS.md) with behavioral tests that drive the real _handle_model_command:
  assert the resolver runs off the loop thread and that the warning enrichment
  is dispatched through asyncio.to_thread.
…_payload

_copy_dist_payload() in profile_distribution.py iterated all staged
entries without consulting the manifest's distribution_owned allowlist,
so manifests that restricted distribution_owned only had cosmetic effect.

Fix: compute manifest.owned_paths() at the top of _copy_dist_payload()
and skip entries not in that set, after the USER_OWNED_EXCLUDE check.

The owned_paths() method already existed on DistributionManifest and
correctly falls back to DEFAULT_DIST_OWNED when no explicit
distribution_owned is set, so the new filter preserves backward
compatibility for existing manifests.

Closes #74373
…ing when omitted

Follow-ups to the previous commit (#74414 by @webtecnica, re #74373):

- When distribution_owned is OMITTED, restore the legacy contract: every
  staged entry outside USER_OWNED_EXCLUDE is copied. The cherry-picked
  filter consulted owned_paths(), which silently narrowed omitted-list
  distributions to DEFAULT_DIST_OWNED and dropped undeclared payload
  (extra top-level files/dirs existing distributions legitimately ship).
- Make explicit allowlists path-aware so documented nested entries like
  skills/research/ and cron/digest.json select exactly that subtree/file
  instead of being dropped by the top-level name comparison. Traversal
  segments (.., absolute) and USER_OWNED_EXCLUDE roots are still rejected.
- Regression tests: omitted-list legacy behavior + nested-path allowlist.
The embedded Hindsight daemon's profile env file carries the plaintext
HINDSIGHT_API_LLM_API_KEY but was written via bare write_text(), leaving
it with umask-derived (typically world-readable) permissions.

- Create/truncate the file via os.open(..., 0o600); chmod a pre-existing
  file to 0600 BEFORE writing new secret bytes.
- Post-write validation on POSIX: verify 0600, retry chmod, and raise if
  the file still isn't owner-only.
- If validation fails, unlink the secret file so a plaintext key is never
  left behind with unverified permissions.
- Regression tests under tests/plugins/ for fresh-write mode, tightening a
  pre-existing 0644 file, and cleanup on validation failure.

Narrowed reimplementation of #74236 confined to plugins/memory/hindsight/;
the core utils.py atomic-replace opt-out from the PR was dropped.

Co-authored-by: carrion256 <carrion256@proton.me>
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on dfef31e

ℹ️ Info

Desktop E2E visual evidence · View test artifacts · View job

3 visual diffs.

inline evidence upload failed.

Failed to upload diff-1508682a2ae8-boot-ready-diff.png with gh image (exit code 1): Error uploading /home/runner/work/_temp/e2e-evidence/diff-1508682a2ae8-boot-ready-diff.png: step 0 (get upload token): uploadToken not found on repo page — do you have write access to NousResearch/hermes-agent? (or, if NousResearch enforces SAML SSO, authorize at https://github.com/orgs/NousResearch/sso)

@teknium1
teknium1 merged commit fae0c4f into main Aug 1, 2026
38 checks passed
@teknium1
teknium1 deleted the salvage/gateway-misc-fixes branch August 1, 2026 05:39
@teknium1 teknium1 added the area/memory Memory subsystem: store, providers, sync, background reviews label Aug 1, 2026
@webtecnica

Copy link
Copy Markdown
Contributor

Thanks @teknium1 for the salvage — the allowlist enforcement from #74414 landed cleanly, and the follow-ups (omitted-list legacy behavior + path-aware matching) are solid improvements over my original. Appreciate the credit! 🙏

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins area/profiles Multi-profile isolation, HERMES_HOME scoping sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/memory Memory subsystem: store, providers, sync, background reviews area/profiles Multi-profile isolation, HERMES_HOME scoping comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

distribution_owned does not constrain profile distribution copy/update payload

4 participants