Skip to content

fix(openviking): harden tool gating, config auth, and runtime shutdown (salvage #51952) - #69221

Merged
kshitijk4poor merged 17 commits into
NousResearch:mainfrom
kshitijk4poor:salvage-51952
Jul 24, 2026
Merged

fix(openviking): harden tool gating, config auth, and runtime shutdown (salvage #51952)#69221
kshitijk4poor merged 17 commits into
NousResearch:mainfrom
kshitijk4poor:salvage-51952

Conversation

@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Summary

Re-lands #51952 (OpenViking hardening consolidation by @ehz0ah) onto current main after the session-context/orphan-recovery chain (#58871 salvage and follow-ups) landed mid-flight and made the original branch conflict, plus two review follow-up commits that harden degraded-mode behavior and widen the .env injection fix to the core writer it was forked from.

All 15 original commits are cherry-picked with authorship preserved:

Conflict resolution vs current main

  • __init__ state: kept main's run-lock/pending-session fields alongside the PR's _env_refresh_enabled and _client_refresh_lock.
  • prefetch(): main's session-start memory context path kept; gate is now _ensure_client() (the PR's intent) instead of the raw _client check.
  • Runtime waiter: main's _recover_pending_sessions() call preserved on successful attach, run outside the refresh lock.

Review follow-ups (2 commits by @kshitijk4poor)

  • fix(openviking): failed-config retry cooldown (30s) — a down endpoint no longer costs a 3s health probe under _client_refresh_lock plus a warning on every provider access (prefetch, sync_turn, tool calls, session end); retries resume after cooldown or immediately on config change, and the log message now says so. Atomic _conn_snapshot (published only after a passing health check) so lock-free background writers (_new_client, on_memory_write) can't observe a torn old/new identity mix or target an endpoint that never passed health. _env_refresh_enabled set at the top of initialize() so a swallowed mid-init exception can't leave the provider stuck in never-refresh mode. _search_prefetch_context reuses _new_client() and degrades to "" on construction failure.
  • fix(memory-setup): the sibling .env injection gap — hermes_cli/memory_setup.py::_write_env_vars (the core writer the plugin copy was forked from; fed by interactive pasted API keys; reused by other memory plugins) gets the same _env_line_safe() sanitization, matching config.save_env_value's existing newline strip.

Both follow-ups are mutation-checked: neutering the cooldown, publishing the snapshot on failed health, or reverting the core sanitizer makes the new regression tests fail.

Related Issue

Refs #21130

Supersedes #51952 (branch conflicted after the recent OpenViking session-context chain landed). Originals #50315, #21138, #49832, #21136, #59454, #18166 already closed with credit.

Validation

Check Result
scripts/run_tests.sh (6 targeted files) 365 passed, 0 failed
Live probes (real imports, temp HERMES_HOME): env-writer injection, _ensure_client refresh, real-thread shutdown join, disabled-toolset gate truth table, subprocess SIGABRT regression 5/5 PASS
ruff (all changed files) clean
scripts/check-windows-footguns.py clean
git diff --check clean
Mutation checks on new guards (cooldown, snapshot, core sanitizer) all fail when reverted

pprism13 and others added 17 commits July 22, 2026 14:46
`_write_env_vars` in the OpenViking memory provider interpolates each
secret straight into a `KEY=VALUE` line, but the values only ever pass
through `_clean_config_value`, whose `value.strip()` trims surrounding
whitespace and leaves internal CR/LF intact. Because the file is strictly
line-oriented and is re-read via `read_text().splitlines()`, a value that
carries an embedded newline spills onto a second physical line, and the
tail is re-parsed as an independent `KEY=VALUE` entry on the next round
trip. A secret pasted with a trailing record (e.g. an `OPENVIKING_API_KEY`
copied with an extra line) therefore injects an arbitrary additional
variable into the persisted credentials file and silently corrupts it.

The fix neutralizes the line terminators at the single chokepoint where
values reach the file. A small `_env_line_safe` helper strips `\r`, `\n`,
and the NUL byte from each value, and both write sites in `_write_env_vars`
(the existing-key update branch and the appended-key branch) route through
it, so a value can only ever occupy the single line it is written on.

## What does this PR do?

Hardens the OpenViking memory provider's `.env` writer so a malformed or
pasted secret value can no longer break out of its `KEY=VALUE` line and
inject a rogue variable into the profile-scoped credentials file.

## Related Issue

N/A

## Type of Change

- [x] 🐛 Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `plugins/memory/openviking/__init__.py`: add `_env_line_safe()` which
  removes `\r`, `\n`, and `\x00` from a value, and apply it to both the
  updated-key and appended-key write branches in `_write_env_vars()`.
- `tests/plugins/memory/test_openviking_provider.py`: add two regression
  tests covering a fresh write and an in-place key update with embedded
  CR/LF, asserting no injected line survives the read-back.

## How to Test

1. Run the targeted tests:
   `pytest tests/plugins/memory/test_openviking_provider.py -k env_writer -q`
2. Reverting the `_env_line_safe` sanitization makes
   `test_openviking_env_writer_strips_embedded_newlines_in_values` and
   `test_openviking_env_writer_strips_newlines_when_updating_existing_key`
   fail with a rogue `INJECTED_KEY=`/`ROGUE=1` line appearing in the file,
   confirming the tests pin the bug.
3. `ruff check plugins/memory/openviking/__init__.py` and
   `python scripts/check-windows-footguns.py plugins/memory/openviking/__init__.py`
   both pass.

## Checklist

### Code

- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains only changes related to this fix
- [x] I've run the relevant tests and they pass
- [x] I've added tests for my changes (required for bug fixes)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)

### Documentation & Housekeeping

- [x] I've updated relevant documentation (docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — N/A
- [x] I've considered cross-platform impact (strips CR as well as LF) — done
- [x] I've updated tool descriptions/schemas if I changed tool behavior — N/A

(cherry picked from commit f29dd2d)
initialize() snapshots OPENVIKING_* into the provider once, so /reload
(which only updates os.environ) leaves viking_* tools running against
stale auth — users have to restart hermes to pick up keys added to
~/.hermes/.env after startup.

Add _ensure_client(), which re-resolves the connection settings via the
same _resolve_connection_settings/_load_hermes_openviking_config path
initialize() uses and rebuilds + health-checks the client only when an
OPENVIKING_* value actually changed; otherwise it reuses the cached
client so the hot path stays at one dict comparison with no network
calls. Every `if not self._client:` guard in system_prompt_block,
queue_prefetch, sync_turn, on_session_end, on_memory_write and
handle_tool_call now goes through it.

Refreshing is gated behind a flag set at the end of initialize() so the
baseline is established before any env re-resolution happens — callers
that wire up a client directly keep the existing client untouched.

Refs NousResearch#21130

(cherry picked from commit b694d21b7c4ff0330df6051e12dc8991f7ea10a6)
…t-exit)

`OpenVikingMemoryProvider.shutdown()` joins in-flight writers, deferred-commit
threads, and prefetch threads, but not `_runtime_start_thread` — the tracked
`daemon=True` waiter that runs `_finish_runtime_openviking_start`, which blocks
on network health probes (`_wait_for_openviking_health` polling + a
`_VikingClient.health()` request).

If the local OpenViking runtime is slow or unreachable, that waiter can still
be blocked in network I/O at interpreter exit. CPython then forcibly kills it
during `Py_FinalizeEx` (`PyThread_exit_thread` -> `__pthread_unwind` ->
`abort()`), producing SIGABRT (exit 134) with no traceback — the same daemon-
thread-at-exit failure class fixed for the Honcho provider.

Fix:
- `shutdown()` now joins `_runtime_start_thread` (timeout-bounded) alongside the
  other tracked threads.
- `_wait_for_openviking_health()` gains a `should_stop` callback; the waiter
  passes `lambda: self._shutting_down` so the poll loop bails out promptly once
  `shutdown()` flips the flag, instead of lingering up to the 60s autostart
  timeout and timing out the join (which would leave the thread alive).
- Add tests/plugins/memory/test_openviking_shutdown.py covering the short-circuit
  and the shutdown-joins-runtime-thread behaviour.

(cherry picked from commit 5471ec70210b35462450aa52f0cd483439fffba7)
Add the AUTHOR_MAP entry required for the salvaged NousResearch#49832 OpenViking shutdown fix so contributor attribution CI can resolve the original author.
…ard-coding strings

The _needs_trusted_identity_retry method was hard-coding specific
server-side error strings to detect when a request failed due to
missing X-OpenViking-Account / X-OpenViking-User headers.  Each new
server-side error variant required another string added to the client.

Replace the string enumeration with a structural match: the error
message mentions one of the tenant headers AND the HTTP status is 400.
This covers all current error variants:

  - "Trusted mode requests must include X-OpenViking-Account and User"
  - "ROOT requests to tenant-scoped APIs must include X-OpenViking-Account"
  - "Trusted mode requests must include X-OpenViking-Account."
  - "Trusted mode requests must include X-OpenViking-User."

The 400 status guard avoids false-positives on 403 errors such as
"USER API keys cannot override X-OpenViking-User", which must not
trigger a retry.

All 176 existing tests pass.

(cherry picked from commit 5a24d67)
Route refreshed unreachable local OpenViking configs through the existing runtime recovery path so /reload can attach to a locally starting server instead of disabling memory until restart.

(cherry picked from commit 040e18a)
Avoid spawning multiple local OpenViking server processes while a runtime autostart waiter is already active. Remote endpoints still retry on later accesses because they do not install a local waiter.
… atomically

Follow-up hardening on the salvaged _ensure_client() (NousResearch#21130 fix):

- Failed-config cooldown: after a refresh attempt fails for a given
  resolved config, skip re-probing for 30s. Previously every provider
  access against a down endpoint paid a 3s health probe under
  _client_refresh_lock and emitted a warning (2+ per turn, some on
  user-facing threads: prefetch, tool calls, session end). Retries
  still happen after the cooldown or immediately when config changes,
  and the log message now says so instead of the false 'disabled until
  config changes'.
- Atomic connection snapshot: _conn_snapshot (5-tuple, single
  assignment) is published only after a health check passes.
  _new_client() and on_memory_write's writer read it as one load, so
  background writers can no longer observe a torn mix of old/new
  identity fields mid-refresh or target an endpoint that never passed
  health. Field writes in _ensure_client_locked keep tracking the
  attempted config for the unchanged-config dedupe.
- _env_refresh_enabled moves to the top of initialize(): an exception
  mid-initialize (swallowed by MemoryManager) can no longer leave the
  provider silently stuck in never-refresh mode.
- _search_prefetch_context reuses _new_client() and degrades to ''
  on construction failure instead of propagating.

Mutation-checked: neutering the cooldown or publishing the snapshot on
failed health makes the new regression tests fail.
Widens the salvaged .env injection fix (NousResearch#50315) to the sibling site it
missed: hermes_cli/memory_setup.py::_write_env_vars is the near-identical
core writer the openviking plugin's copy was forked from, is fed directly
by interactive _prompt() (pasted API keys), and is reused by other memory
plugins (e.g. supermemory imports it). A pasted secret with an embedded
CR/LF injected an arbitrary extra KEY=VALUE line on the next read.

Same _env_line_safe() treatment as the plugin writer (strip every
str.splitlines() separator + NUL), matching config.save_env_value's
existing newline strip. Mutation-checked: reverting the sanitizer makes
the new regression tests fail.
@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins tool/memory Memory tool and memory providers tool/mcp MCP client and OAuth P3 Low — cosmetic, nice to have sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jul 22, 2026
@kshitijk4poor
kshitijk4poor merged commit 7e3acd0 into NousResearch:main Jul 24, 2026
41 checks passed
@kshitijk4poor
kshitijk4poor deleted the salvage-51952 branch August 5, 2026 07:08
egilewski added a commit to egilewski/hermes-agent that referenced this pull request Aug 19, 2026
PR NousResearch#69221 fixed the reported `disabled_toolsets=["memory"]` provider-tool
bypass, but the shared injection gate still recognized only that literal.
The valid `all` and `*` aliases removed registry tools and then allowed memory
providers to re-add their schemas after filtering.

Treat both global aliases as memory-provider denials in the gate shared by
initial injection and MCP refresh. Explicit memory denial and enabled-provider
behavior remain unchanged.

Fixes NousResearch#49386
Related NousResearch#46171
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state tool/mcp MCP client and OAuth tool/memory Memory tool and memory providers type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants