Commit graph

7315 commits

Author SHA1 Message Date
Teknium
249c69b958
fix(gateway): per-profile pairing whitelist isolation in multiplex mode (#53045 salvage) (#59330)
* fix(gateway): per-profile pairing whitelist isolation for multiplex gateways

Pairing approvals are stored per profile (profiles/<name>/pairing/) and
authz routes pairing checks through the serving profile's store, so one
profile's approved users no longer authorize against every other
profile's whitelist in multiplex mode.

The global store remains for the hermes pairing CLI and single-profile
gateways; unregistered/unstamped sources fall back to it, preserving
existing behavior.

Salvaged from PR #53045 (pairing half). The SOUL.md half was dropped:
the agent turn already runs inside _profile_runtime_scope on main, so
load_soul_md() resolves per-profile without changes.

Original work by @soddy022.

* ci: redispatch after arm64 docker dashboard-slot flake (unrelated to this PR)

---------

Co-authored-by: soddy022 <290613374+soddy022@users.noreply.github.com>
2026-07-07 18:25:52 +10:00
Teknium
088b989442
fix(gateway): scope reset banners' session info to the serving profile (#59048 salvage) (#59329)
* fix(gateway): scope reset banners' session info to the serving profile

The auto-reset notice and the manual /reset //new banner both appended
_format_session_info() outside any profile scope, so a multiplexed
gateway advertised the base config's model/provider/context while the
session actually ran on the profile's.

Route both call sites through a new _reset_notice_session_info(source),
which enters _profile_runtime_scope for the source's profile when
gateway.multiplex_profiles is on (mirroring _run_agent's gating), so
_load_gateway_config()/_resolve_gateway_model() resolve the profile's
config.yaml via the existing context-local home override. Single-profile
gateways never enter the scope — behavior unchanged.

Both call sites invoke the helper via asyncio.to_thread: under the
scope, resolution can do blocking work (credential refresh,
context-length HTTP probes) that previously failed fast unscoped and
must not run on the event loop.

Fixes #59003

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

* chore(release): map irresi author email for PR #59048 salvage

---------

Co-authored-by: irresi <blueirobin02@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:25:45 +10:00
Teknium
f1fde49e45
fix(gateway): avoid cross-profile session recovery (#59325)
Co-authored-by: yoma <yingwaizhiying@gmail.com>
2026-07-07 18:25:39 +10:00
Teknium
d297568299
fix(gateway): detect config token credential collisions (#59321)
Co-authored-by: markoub <2418548+markoub@users.noreply.github.com>
2026-07-07 18:25:33 +10:00
Ben
5eac665252 feat(status): expose nous_session_valid on /api/status for hosted-agent self-heal
A hosted agent whose Nous bootstrap session dies terminally (invalid_grant /
quarantine) looks HEALTHY to every liveness/connectivity probe — the machine,
relay ws, and dashboard all stay up — yet every inference turn hard-fails with
a provider-auth error until a human re-logs-in. Nothing currently surfaces that
condition to NAS.

Add get_nous_session_validity() (valid|terminal|unknown), classified from local
auth-store state (no working token required), and report it on the public
/api/status payload. NAS's 2-min health sweep reads it and re-mints the
bootstrap session in place on 'terminal'.

Anti-flap: only a terminal failure (relogin_required / persisted quarantine
marker with tokens cleared) maps to 'terminal'; transient/mid-rotation blips and
merely-expiring tokens report 'unknown' so a healthy box never triggers a
spurious re-mint.

Part of the hosted-agent bootstrap-session self-heal (NAS side reads this field).
2026-07-07 00:53:56 -07:00
Ben
182256206a test: drop worktree-path sanity guard that fails in CI
The test_module_resolves_to_this_worktree guard asserted auth.__file__ contained
'worktrees/bootstrap-h2-logging' — a local dev crutch to defeat the editable-
install trap (venv points at the main checkout). In CI the code lives at
/home/runner/work/... so the assertion always fails. It never belonged in the
committed suite; the 5 behavioural tests are what matter.
2026-07-07 00:53:48 -07:00
Ben
444dc0da89 feat(auth): log forensic detail at Nous quarantine so terminal auth death is visible
A NAS-hosted Fly agent's Nous bootstrap session can take a terminal
invalid_grant and get quarantined in _quarantine_nous_oauth_state, which
clears the dead tokens from auth.json. Until now this quarantine was
completely silent: the only signal was a downstream "No access token found"
WARNING once the credential pool was already empty, which is too late to
root-cause. Because the Fly log drain is WARNING-only, nothing about the
terminal death reached centralized logging, and a real incident could not be
diagnosed because the evidence was never recorded.

Emit a WARNING+ forensic record AT the quarantine point, before the token
material is cleared. Fields: refresh_token hash prefix (12-char SHA-256 hex,
correlates to NAS's refreshTokenHash), client_id, agent_key_id, error code,
reason, auth.json path/size/mtime/exists, and whether the token was already
past its own expiry. WARNING level is deliberate — INFO never reaches the Fly
drain.

Redaction safety (load-bearing): the log dict is built only from computed
values (hash prefix, sizes, booleans). No raw refresh_token, access_token, or
agent_key bytes are ever passed into the log call, avoiding Hermes's known
credential-literal corruption bug class. A test asserts the raw refresh token
substring is absent from all emitted log output.

Note: no session_id field exists on Nous auth state; provenance is captured
via client_id + agent_key_id, which are non-secret routing identifiers.
2026-07-07 00:53:48 -07:00
Ben Barclay
536ffedbf4
feat(docker): re-seed a terminally-dead Nous bootstrap session on boot (#59983)
The stage2-hook auth.json seed is first-boot-only ([ ! -f auth.json ]) to avoid
clobbering rotated refresh tokens on restart. That guard means a container whose
Nous bootstrap session took a terminal invalid_grant (tokens cleared,
providers.nous.last_auth_error.relogin_required stamped) cannot recover from a
restart — it stays unauthenticated until the credential is replaced.

Add a self-heal path: an orchestrator that manages the container supplies a
freshly-issued session via HERMES_AUTH_JSON_REBOOTSTRAP (distinct from the
create-only *_BOOTSTRAP var). On boot, scripts/docker_rebootstrap_nous_session.py
swaps ONLY the providers.nous entry, and ONLY when the on-disk entry is provably
terminal (quarantine marker + no usable tokens). Healthy/rotating/absent/
unparseable auth.json is always a no-op, so the env is safe to leave set across
restarts and never clobbers a good token. Pure stdlib, runs as its own
subprocess, always exits 0 so a re-seed error never fails the boot.

Reuses the same terminal predicate as get_nous_session_validity() so we re-seed
only a session that is genuinely dead.
2026-07-07 06:57:23 +00:00
kshitij
586aae4bf1
Merge pull request #60034 from kshitijk4poor/salvage/59523-zai-overload-backoff
fix(agent): run Z.AI Coding overload adaptive backoff on the overloaded path
2026-07-07 12:07:11 +05:30
kshitijk4poor
130e2337c2 fix(usage): scope Codex usage pool fallback to AuthError, keep singleton token on account_id read failure
Follow-up hardening on the cherry-picked pool-fallback fix. The original
_resolve_codex_usage_credentials wrapped BOTH resolve_codex_runtime_credentials()
and the separate _read_codex_tokens() account_id read in one broad
'except Exception: pass', which had three problems:

1. A transient refresh/network failure (non-AuthError) from the resolver was
   silently swallowed and downgraded to pool.select(), which could report
   /usage limits for a DIFFERENT pool account than the one actually running.
   On main that error surfaced. This is a real behavior regression for the
   multi-account/pool case.
2. If the resolver succeeded but only the account_id read raised, the whole
   singleton tier was abandoned in favor of a pool token that carries no
   ChatGPT-Account-Id header (PooledCredential has no account_id concept),
   risking a wrong-account read or 401.
3. 'except Exception' masked genuine programming errors.

Fix: narrow the outer catch to AuthError (the documented 'no creds' failure
mode of both functions), and read account_id in a best-effort inner try so a
partial/missing singleton store can't sink an otherwise-usable credential.
Transient errors now propagate and fail open via the outer fetch_account_usage
guard rather than mis-routing to the wrong account. Adds debug breadcrumbs and
a comment characterizing when the tier-3 pool path actually fires.

Guard tests: a non-AuthError resolver failure must NOT swap to the pool
(fail-open, no snapshot); an account_id read failure keeps the singleton token.
Updated the existing pool-fallback test to use AuthError (the real failure
mode) instead of a generic RuntimeError.
2026-07-07 12:01:33 +05:30
spiky02plateau
c59b300865 test: lock Codex usage percent polarity 2026-07-07 12:01:33 +05:30
spiky02plateau
b2213ba870 fix: fetch Codex quota from credential pool 2026-07-07 12:01:33 +05:30
kshitijk4poor
45f5a6e659 refactor(retry): single-source Z.AI overload short-attempts + drop change-detector assert
Follow-up on the salvage of #59523. Two low-risk cleanups surfaced by review:

- Extract _ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS as a module constant so
  adaptive_rate_limit_backoff() and zai_coding_overload_retry_ceiling()
  share one source of truth. Previously both hardcoded short_attempts=3
  independently; tuning one without the other would silently desync the
  retry ceiling from the backoff schedule.
- Replace the tautological formula-mirroring assert in
  test_zai_overload_retry_ceiling_exceeds_short_attempts with a behavior
  invariant (ceiling leaves headroom for every long-backoff entry), per the
  repo's contracts-over-snapshots testing rule.
2026-07-07 11:57:01 +05:30
xxxigm
ba03c5ab27 test(retry): cover Z.AI overload retry ceiling reachability
Assert the invariant that the Z.AI overload retry ceiling exceeds the
short-retry threshold (the original bug had them equal, so the long tier
was dead code), and walk the attempt range the retry loop actually
traverses to prove the full 30/60/90/120s long-backoff schedule now runs.
2026-07-07 11:44:58 +05:30
teknium1
d42e9b1788 fix(auxiliary): recover from stale fallback-candidate credentials instead of aborting
A fallback candidate can itself carry a stale credential (e.g. an
expired ANTHROPIC_TOKEN picked up by _try_anthropic). Its 401 previously
propagated out of the fallback call site and aborted the auxiliary task
— for compression: a 60s cooldown + context marker while the session
kept growing past the context cap. Live case: mattalachia debug dump
(Jul 2026), Codex timeout → Anthropic 401 x5 → 296K 'Cannot compress
further'.

Now each fallback candidate call is wrapped: on auth error, refresh the
candidate's provider credentials and retry once; if unrefreshable, mark
the provider unhealthy and walk the discovery chain again so the next
viable candidate serves. Sync + async paths. Non-auth errors still
raise unchanged.
2026-07-06 18:12:24 -07:00
Fan Yang
f69e3aadf1 fix(auxiliary): refresh auto-routed provider credentials on 401
Infer the concrete auxiliary auth provider from the selected client base
URL so provider:auto routes can refresh Copilot/Codex/Anthropic/Nous
credentials after auth errors, instead of skipping refresh because
resolved_provider stayed 'auto'. Adds the copilot branch to
_refresh_provider_credentials and evicts the stale auto-route cache
before retrying.

Fixes #20832. Salvaged from PR #20837, reapplied surgically onto current
main (branch predated the _retry_same_provider_sync/async extraction).
2026-07-06 18:12:24 -07:00
helix4u
4b4f058860 fix(tui): probe active custom model provider 2026-07-06 13:08:50 -07:00
Teknium
70c6ae609e
fix(tui): stop hermes --tui -m from persisting the model globally (#59805)
The -m flag seeds HERMES_MODEL/HERMES_INFERENCE_MODEL for the launched TUI
process only. But the per-turn config sync (_sync_agent_model_with_config)
computed its target via _config_model_target(), which fell back to those
env vars whenever config.yaml had no model.default — the normal state for
custom-provider-only setups. The sync then replayed the -m model as a
/model switch, and with model.persist_switch_by_default (default true)
_persist_model_switch wrote model.default/provider/base_url into
config.yaml. A one-shot CLI flag became the permanent global model,
visible in every new session and every model picker.

Two-sided fix:
- _config_model_target() no longer falls back to the env seed. Empty
  model = config expresses no preference = sync is a no-op. The agent
  keeps the session-scoped -m model; config.yaml edits still sync.
- _apply_model_switch() gains persist_override; all three internal
  callers (config sync, /moa one-shot swap, /moa post-turn restore) pass
  persist_override=False so session-mechanical switches can never write
  config.yaml regardless of the persist-by-default setting. User-typed
  /model keeps its existing flag/config behavior.

E2E-verified against an isolated HERMES_HOME with a custom-provider-only
config + -m env seed: sync no longer fires, config.yaml byte-identical,
_resolve_model() still returns the seed for the session's own agent.
2026-07-06 12:46:40 -07:00
teknium1
5de42325db test: expect model slug in autoraise notice dict (follow-up to gpt-5.4 extension) 2026-07-06 12:46:20 -07:00
AIalliAI
60391d0eef fix(agent): don't apply Codex gpt-5.5 autoraise notice when an external context engine is active
When context.engine selects a plugin engine (e.g. LCM), the host
compression threshold — including the Codex gpt-5.5 50% -> 85%
autoraise — only configures the built-in ContextCompressor and never
reaches the plugin. The autoraise notice still fired, telling the user
auto-compaction was raised when nothing actually changed, and the
startup context-limit line printed the host percent next to the
engine's own threshold_tokens, contradicting itself.

- Clear _compression_threshold_autoraised when a plugin engine is
  selected, suppressing both the CLI startup notice and the gateway
  turn-1 replay via _compression_warning.
- Print the active engine's own threshold_percent in the startup
  context-limit line so percent and token count agree.
- Built-in behavior is preserved, including the fallback path where a
  configured engine fails to load and the built-in compressor takes
  over.

Fixes #44439
2026-07-06 12:46:20 -07:00
Sanidhya Singh
fff2408961 fix(agent): dedupe Codex gpt-5.5 autoraise notice across agent inits
The Codex gpt-5.5 compaction-threshold autoraise notice re-fired on every
agent init. Because the gateway rebuilds the agent per inbound message, the
notice spammed long-running Discord/Telegram/etc. sessions, and the only
documented remedy (`compression.codex_gpt55_autoraise false`) disables the
useful autoraise behavior itself.

Gate both emission surfaces — the CLI startup print and the gateway
`_compression_warning` replay — on a persisted per-profile marker under
`$HERMES_HOME` (`.codex_gpt55_autoraise_notice`), keyed on the from→to
percentages the notice displays. The notice now shows at most once per
profile; the autoraise still fires and `codex_gpt55_autoraise: false` still
disables it; and a later change to the raised threshold re-notifies once.
Docs updated to match.
2026-07-06 12:46:20 -07:00
sasquatch9818
bdca94e749 fix(compression): keep Codex gpt-5.5 autoraise from lowering a higher threshold
The Codex gpt-5.5 compaction autoraise (#40957) overrode the effective
threshold unconditionally. If a user had set compression.threshold above
0.85, agent_init dropped them down to 0.85. That wastes usable window and
contradicts the feature's whole point: use more of the context, not less.
It happened silently too, since the one-time notice is suppressed when the
override doesn't raise.

The override is an autoraise. It must only raise. Pulled the apply logic
into a small pure helper that clamps the Codex case to never lower a
higher-or-equal user threshold, and emits the notice only when it actually
fires. Other overrides (Arcee Trinity) keep their existing unconditional
behavior.

Fixes the Codex gpt-5.5 compaction autoraise lowering a user's higher
configured threshold. A user on the Codex OAuth route with
compression.threshold > 0.85 was silently clamped to 0.85, compacting
earlier than they asked and using less of the 272K window the feature was
meant to unlock. The autoraise now only ever raises.

N/A

- [x] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ]  New feature (non-breaking change that adds functionality)
- [ ] 🔒 Security fix
- [ ] 📝 Documentation update
- [ ]  Tests (adding or improving test coverage)
- [ ] ♻️ Refactor (no behavior change)
- [ ] 🎯 New skill (bundled or hub)

- `agent/agent_init.py`: added `_resolve_compression_threshold()`, a pure
  helper that combines the global threshold with a per-model override. The
  Codex gpt-5.5 autoraise never lowers a higher-or-equal user threshold;
  the notice is returned only when it actually raises. Rewired `init_agent`
  to call it, replacing the unconditional `compression_threshold = _model_cthresh`.
- `tests/agent/test_arcee_trinity_overrides.py`: added 5 cases for the
  helper — raise from default, never-lower regression, equal-is-noop,
  no-override passthrough, and non-codex (Trinity) unconditional apply.

1. Set `compression.threshold: 0.90` and run gpt-5.5 on provider `openai-codex`.
2. Before: effective threshold drops to 0.85, no notice. After: stays 0.90.
3. Run `scripts/run_tests.sh tests/agent/test_arcee_trinity_overrides.py`.
   Stash `agent/agent_init.py` and the new cases fail; restore and they pass.

- [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md)
- [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix/feature (no unrelated commits)
- [x] I've run `pytest tests/ -q` and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)

- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
2026-07-06 12:46:20 -07:00
Tranquil-Flow
0b6df665a9 fix(compression): autoraise gpt-5.3-codex-spark threshold to 70% (#48621)
gpt-5.3-codex-spark has a native 128K context window but the default
50% compaction trigger fires at ~64K, wasting half the usable window
before the session has accumulated enough turns to summarize
meaningfully.  This raises the trigger to 70% (~90K) on the Codex OAuth
route only, leaving ~38K headroom for the summary and continued
conversation before the 128K hard limit.

The override is not gated by allow_codex_gpt55_autoraise because 128K
is the model's native window (unlike gpt-5.5's artificial 272K Codex
cap).  Non-Codex routes are unaffected.

Also adds a boundary regression test verifying the short-session
scenario from the issue always yields a non-empty compressible window
(no silent context wipe).
2026-07-06 12:46:20 -07:00
Tanmay Choudhary
948993cd62 feat(compression): extend Codex 272K compaction autoraise to gpt-5.4
The ChatGPT Codex OAuth backend caps both gpt-5.4 and gpt-5.5 at a 272K
context window, but the autoraise that lifts the compaction trigger to 85%
only matched gpt-5.5. On gpt-5.4 the global 50% threshold fired at ~136K —
half the usable window — compacting far earlier than necessary.

Rename _is_codex_gpt55 -> _is_codex_gpt54_or_gpt55 and match both families.
The one-time user notice is now model-aware (shows the actual slug). The
config key codex_gpt55_autoraise is kept as-is for backward compatibility.
Adds gpt-5.4 coverage to the autoraise tests.
2026-07-06 12:46:20 -07:00
Tranquil-Flow
370a489fb4 fix(auxiliary): floor compression timeout so reasoning models don't fall back to marker (#54915) 2026-07-06 12:44:10 -07:00
edgemass
78ee0aa367 [verified] fix: account for codex replay in compression tail budget 2026-07-06 12:43:59 -07:00
teknium1
586acf5307 feat(curator): add hermes curator usage — all-skills usage view
Surfaces the usage_report()/provenance() data layer added in #36701 as a
user-facing CLI command. Unlike `hermes curator status` (scoped to
curator-managed agent-created candidates), `usage` lists every skill on disk
— bundled built-ins and hub-installed included — with per-skill use/view/patch
counts and an agent/bundled/hub provenance tag.

Flags: --sort {activity,recent,name}, --provenance {agent,bundled,hub} filter,
--json for machine-readable output.
2026-07-06 12:26:21 -07:00
teknium1
94cdd56b82 feat(plugins): surface entry-point plugins in hermes plugins list
Salvaged from #40346; re-verified on main, tightened, tested.

Co-authored-by: tjboudreaux <tjboudreaux@users.noreply.github.com>
2026-07-06 11:20:47 -07:00
brooklyn!
91c68bf834
Merge pull request #55923 from NousResearch/bb/serve-headless-no-web-build
feat(cli): make hermes serve a real headless backend (no web UI build/mount, neutral ready sentinel)
2026-07-06 13:18:00 -05:00
teknium1
51e6ef5fca feat(banner): size skills display to terminal width instead of fixed 8/47
Salvaged from #40273; re-verified on main, tightened, tested.

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
2026-07-06 11:17:41 -07:00
Teknium
7dfd5077ce
feat(oneshot): add --usage-file JSON usage report to hermes -z (#59615)
* feat(oneshot): add --usage-file JSON usage report to hermes -z

Pipelines driving hermes -z (batch reviewers, cron scripts, eval
harnesses) had no way to account for per-invocation spend: the agent
computes estimated_cost_usd and full token counts internally, but
oneshot mode discards everything except the final response text.

- hermes -z PROMPT --usage-file PATH writes a JSON report after the
  run: estimated_cost_usd, cost_status/source, input/output/cache/
  reasoning/total tokens, api_calls, model, provider, session_id,
  completed, failed.
- Written even when the run fails (with a failure field) so callers
  can always account for spend; the write itself is best-effort and
  never masks the run's own outcome.
- Flag registered in both the full parser and the Termux fast path;
  added to both value-flag scan sets so profile detection stays
  correct.

Validation: 6 unit tests + live E2E (real -z run produced a report
with real OpenRouter cost + token counts).

* test: include usage_file kwarg in oneshot dispatch assertions

The two dispatch tests assert the exact kwargs dict passed to
run_oneshot; the new usage_file kwarg must appear there.
2026-07-06 11:10:51 -07:00
Brooklyn Nicholson
409560a7d9 Merge remote-tracking branch 'origin/main' into bb/serve-headless-no-web-build 2026-07-06 12:51:15 -05:00
hellno
d7348bf24b fix(interrupt): run user-approved commands from a clean interrupt slate
A user-approved terminal/execute_code command could be SIGINT-killed
(exit 130 + "[Command interrupted]") by a stale interrupt bit that landed
on the execution thread during the blocking approval-wait, while the
result still carried the "...approved by the user." note. The terminal
tool runs sequentially inline on the execution thread, and nothing
cleared or re-checked the bit between approval-grant and env.execute.

Clear the current thread's interrupt bit once before an approved command
spawns its child (terminal foreground; execute_code local + remote), and
enrich the note to "...approved by the user, then interrupted." on a
genuine post-start interrupt instead of implying success. A genuine
interrupt arriving after execution starts (or during a retry backoff)
still SIGINTs the command; non-approved commands keep current behavior.

Adds regression tests covering stale-bit-clears, genuine-interrupt-still-
kills, the retry-backoff window, natural-exit-130 (not mislabeled), and
execute_code local + remote.
2026-07-06 04:58:42 -07:00
teknium1
8235f484c9 feat(secrets): adapt 1Password onto the SecretSource interface
Follow-up on the cherry-picked #36896 commits, wiring 1Password into
the new registry as the reference *mapped* source:

- OnePasswordSource adapter (shape=mapped, scheme=op): fetch-only —
  precedence, override semantics, conflict warnings, and env writes
  move to the orchestrator; apply_onepassword_secrets kept as legacy
  shim like Bitwarden's.
- Registered in _ensure_builtin_sources; mapped op:// bindings now
  outrank bulk Bitwarden project dumps on contested vars.
- _cache.py FetchResult/is_valid_env_name re-exported from base so
  there is exactly one canonical definition; bitwarden.py re-adapted
  onto the contributor's DiskCache substrate.
- ErrorKind classification for op failures (auth/binary/empty/network).
- Registry + conformance coverage for OnePasswordSource, incl. the
  headline multi-source test: both vaults claim the same var, mapped
  1Password wins, conflict surfaced, provenance correct.
- env_loader tests migrated off the legacy apply_* mocks onto the
  fetch layer; AUTHOR_MAP entry for @hwrdprkns.
2026-07-06 04:58:07 -07:00
Taylor H. Perkins
8a76de962f fix(secrets): make 1Password bootstrap token reliable outside systemd
The 1Password secret source resolves op:// references using
OP_SERVICE_ACCOUNT_TOKEN read from os.environ. Under systemd the gateway
gets that token via EnvironmentFile, but cron jobs, subprocesses, CLI
runs, macOS launchd, and Docker containers spawn fresh interpreters with
no inherited shell state — so they silently failed to resolve any
reference and fell back to empty strings.

Two patches close the gap, matching Bitwarden's reliability guarantees:

1. env_loader: auto-load ~/.hermes/.op.env after .env so the gitignored
   bootstrap token is available everywhere. override=False plus an
   explicit guard ensure it never clobbers a token already in env (e.g.
   from a systemd EnvironmentFile, which keeps precedence).

2. credential_pool: _get_env_prefer_dotenv() now prefers the resolved
   value in os.environ when .env still holds a raw op:// reference,
   instead of handing a URL to provider auth. Non-op:// values keep the
   existing .env-takes-precedence behaviour.

Also gitignore .op.env, document the three bootstrap-token options, and
add tests covering auto-load, no-override, and the resolved-vs-raw
precedence (plus regression guards).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 04:58:07 -07:00
Taylor H. Perkins
5c4c0e9d9b feat(secrets): add 1Password (op://) secret source
Resolve provider credentials from 1Password op://vault/item/field references
at startup via the official `op` CLI, alongside the existing Bitwarden source.
Users map env-var names to references in secrets.onepassword.env; after .env
loads, each is resolved with `op read` and injected into os.environ. Auth is
whatever `op` already uses (service-account token or desktop/interactive
session) — Hermes never authenticates or installs `op` itself.

Startup-safe and fail-open: a missing binary, expired auth, a bad reference,
or an empty value each warn and fall back to existing credentials, never
blocking startup. Successful, complete pulls are cached in-process and on disk
(<hermes_home>/cache/op_cache.json, 0600) via the shared DiskCache; only
secret values are stored, never the token (auth is fingerprinted into the
key). Adds `hermes secrets onepassword {setup,status,set,remove,sync,disable}`
(aliases op/1password), config defaults, the cli-config example, docs, and
hermetic tests.

Hardening applied across both backends in env_loader: each source runs in its
own guard, config sections are coerced to dict, and cache_ttl_seconds is
coerced defensively — so a malformed secrets: section can't abort startup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 04:58:07 -07:00
teknium1
2d16ec7fb7 feat(secrets): pluggable SecretSource interface + multi-source orchestrator
Introduces a first-class secret-source contract so password managers
(Bitwarden today, 1Password next, third-party vaults as plugins) plug
into one orchestrated startup path instead of each hardcoding into
env_loader.

- agent/secret_sources/base.py: SecretSource ABC (fetch-only contract:
  never raises, never prompts, sync with orchestrator-enforced timeout),
  shared ErrorKind taxonomy, FetchResult, run_secret_cli() minimal-env
  subprocess helper, API versioning for plugin compatibility.
- agent/secret_sources/registry.py: registration gating (name/scheme
  uniqueness, api_version, shape), apply_all() orchestrator owning
  precedence (mapped-beats-bulk, first-claim-wins, override_existing
  never crosses sources, protected bootstrap tokens), conflict warnings,
  per-var provenance, per-source wall-clock timeout.
- Bitwarden converted to a registered BitwardenSource (bulk shape);
  behavior unchanged, apply_bitwarden_secrets kept as legacy shim.
- env_loader._apply_external_secret_sources now drives the orchestrator;
  provenance labels resolve through registry (e.g. '(from 1Password)').
- PluginContext.register_secret_source() for external backends.
- secrets.sources optional ordering key in DEFAULT_CONFIG + example.
- tests/secret_sources/: 47 new tests incl. reusable conformance kit
  (SecretSourceConformance) that plugin authors run against their source.
2026-07-06 04:58:07 -07:00
Teknium
d345b9fbfe
refactor(cron): derive one-shot run-claim TTL from HERMES_CRON_TIMEOUT (#59567)
Follow-up to #59524. The one-shot running-claim stale-recovery window was a
fixed 30-min constant. Derive it from the cron inactivity timeout instead
(HERMES_CRON_TIMEOUT, the same limit the scheduler enforces per run) so the
safety valve tracks how long a run may actually go quiet:

- unset/invalid -> default 600s inactivity -> TTL 1800s (unchanged behaviour)
- positive N    -> max(N * 3 headroom, 1800s floor)
- 0 (unlimited) -> no finite bound -> fall back to the 1800s constant

The fixed constant is kept as the floor + unlimited-case fallback. Resolved
once per due-scan. HERMES_CRON_TIMEOUT is a pre-existing internal env var
(already read by cron/scheduler.py); no new config surface.

E2E: with HERMES_CRON_TIMEOUT=1200 the claim now survives to 60min where the
old fixed 1800s constant wrongly expired it at 30min mid-run. +1 derivation
test; 640/640 cron tests pass.
2026-07-06 04:57:09 -07:00
Teknium
27f74b26c5
fix(web): correct 'disabled plugin' diagnosis for web backends (#59573)
When a bundled web provider (firecrawl, tavily, exa, ...) is listed in
plugins.disabled, its provider never registers and the web_search/
web_extract dispatchers emitted the misleading "No web extract provider
configured. Set web.extract_backend to ..." — even though the backend was
configured correctly. The real fix is to re-enable the plugin.

- web_tools.py + web_search_registry.py: when the configured backend names
  a disabled bundled web plugin, both dispatchers now point the user at the
  actual cause (re-enable the plugin) instead of a wrong config hint.
- plugins_cmd.py cmd_enable: enabling by canonical key now also clears the
  manifest-name alias (web-firecrawl) from plugins.disabled, so the
  suggested command actually re-enables the plugin ('explicit disable wins'
  matches on the name too).
- plugins_cmd.py cmd_toggle / _run_composite_ui / _run_composite_fallback:
  the interactive 'hermes plugins' menu now persists the canonical key
  (web/firecrawl), never the bare manifest name — the drift that put the
  offending entry in plugins.disabled in the first place.

Follow-up to #59518 (which fixed web credential resolution, a different
cause). Fixes the disabled-plugin symptom reported after that PR.
2026-07-06 04:38:17 -07:00
Waseem Shahwan
777cfa81f3 fix(gateway): drop interrupt sentinel before chat delivery (#7921) 2026-07-06 03:53:52 -07:00
Hermes Agent
7487afbd99 test(gateway): update stale expectation — #31884 surfaces retry hint for uninterrupted zero-call drops
The PR predates #31884, which changed the non-interrupted api_calls==0
empty path from silence to a retry hint. Flip the contributed test to
assert the current (correct) behavior.
2026-07-06 03:42:14 -07:00
AIalliAI
a14caf7759 fix(gateway): stop post-/stop stale interrupt from silently swallowing the next message
A /stop sets _interrupt_requested on the session's cached agent, but the
flag is only cleared by the turn finalizer.  When the stopped run is hung
or still draining, the flag survives the forced lock release and the
session's NEXT user message is killed at the top of the tool loop
(conversation_loop.py interrupt check): the run completes with
interrupted=True, api_calls=0 and an empty response, which
_normalize_empty_agent_response passed through as pure silence — the
user's message was swallowed with no trace except a
'response ready: ... api_calls=0 response=0 chars' log line.

Two-layer fix:

- _interrupt_and_clear_session now evicts the cached agent whenever it
  releases the running state.  The next message rebuilds the agent from
  session history (mirroring the /new and /model paths), while the old
  agent object keeps its interrupt flag so a hung drain still dies when
  it unblocks.  This intentionally does NOT clear the flag in place:
  turn_context deliberately preserves a pending interrupt across turn
  start (it carries interrupt-message delivery), and clearing it could
  revive a hung run the user just stopped.

- _normalize_empty_agent_response distinguishes a drain from a swallowed
  turn: an interrupted run that did work (api_calls > 0) stays silent as
  before (deliberate stop/steer; queued messages are delivered by the
  recursive drain inside _run_agent), but an interrupted run with ZERO
  api_calls never processed the user's message at all and now surfaces a
  'send it again' notice instead of nothing.

Same silent-delivery class as a1f76ba7e (#29346), which covered the
extract-stripped case; regression tests added next to that coverage.

Fixes #44212
2026-07-06 03:42:14 -07:00
teknium1
83e6a487eb test(tui_gateway): isolate verification.status not_applicable test from stray tmp markers
test_verification_status_outside_workspace_is_not_applicable passed tmp_path as
the cwd and asserted status == not_applicable, relying on tmp_path having no
project-marker ancestor. _marker_root() walks up to ~6 levels, so a stray marker
in a shared tmp-root ancestor (e.g. a /tmp/package.json left by another tool)
made project_facts_for() resolve tmp_path as a workspace and flip the status to
unverified. Green in clean CI, red on any dev box with a polluted /tmp.

Force the no-facts precondition by monkeypatching project_facts_for -> None so
the test deterministically exercises the not_applicable branch regardless of
ambient filesystem state. Test-only; no production change.
2026-07-06 03:34:16 -07:00
Ahmett101
2e828d4b75 fix(background-review): guard summarize against list-shaped tool responses (#59437)
`summarize_background_review_actions` was structured on the assumption
that every parsed tool response is a fully-typed dict-of-fields. In
practice the memory/skill tools — and their wrappers over Mem0 OSS and
the skill_manage MCP server — sometimes serialize `_change` as a list
or scalar, and clamp `operations` to a single string when the field
came in via a partial JSON bridge.

The original code did the equivalent of:
    change = data.get("_change", {})
    change.get("description", "")

so when `_change` was a list the inner .get crashed with
`AttributeError: 'list' object has no attribute 'get'`, every ~10
turns the user saw the entire background review collapse.

Three defensive guards in summarize_background_review_actions:

- `call_details.get(tcid, {})` → `call_details.get(tcid) or {}` plus
  `isinstance(detail, dict)` coercion. Catches stale scalar/None
  values when a fork inherits partial state from a stale tool_call_id.
- `operations = detail.get("operations") or []` → `isinstance(ops_raw, list)`
  coerce, then per-entry isinstance check before `.get()`. Skips
  non-dict items without raising; an entire surrounding review no
  longer goes down because one entry was malformed.
- `change = data.get("_change", {})` → `isinstance(change_raw, dict)`
  coerce. The originally-reported crash class for skill_manage with
  list-shaped _change now falls through to the generic summary path.

And the caller in `_run_review_in_thread` is wrapped in a try/except
that maps any residual summarize exception to `actions = []` and
emits a 'partial results' warning, so even an entirely unanticipated
shape won't take down the outer review — the user only sees
'Background memory/skill review failed' instead of the prior hard
crash that lost every successful action the fork had completed.

Tests: tests/test_background_review_list_shapes.py — standalone
pytest-free runner, 7/7 PASS:
  a_change_as_list_does_not_crash       (originally-reported shape)
  a_change_as_int_does_not_crash        (scalar fallback)
  b_operations_as_string_treated_as_empty
  b_operations_as_none_treated_as_empty
  c_operations_contains_non_dict_entries (verbose-mode per-entry filter)
  d_detail_non_dict_replaced_with_empty
  e_call_defends_via_try_except         (structural anchor)

Refs NousResearch/hermes-agent#59437
2026-07-06 03:28:30 -07:00
herbalizer404
4a80e27bba fix telegram explicit private thread routing 2026-07-06 03:18:33 -07:00
giggling-ginger
e53e8a782c fix(mcp): sanitize server names for auth env keys
Server names with non-env-safe characters (dots, slashes, spaces)
produced invalid env-var keys like MCP_MY.SERVER_API_KEY or
MCP_GITHUB/MCP_API_KEY, breaking .env writes and ${VAR} header
substitution. _env_key_for_server now replaces any character outside
[A-Za-z0-9_] with an underscore.

Co-authored-by: Hermes Agent <agent@nousresearch.com>
2026-07-06 03:18:07 -07:00
izumi0uu
ef79ad014d fix(dashboard): accept HA ingress prefix paths
Allow mainstream reverse-proxy path mounts to keep their X-Forwarded-Prefix when Home Assistant Supervisor ingress already consumes nearly the old 64-character budget. Keep validation bounded and keep rejected non-empty prefixes diagnosable with a deduplicated warning.

Constraint: HA Supervisor ingress prefixes are 63 chars before add-on subpaths, so the old 64-char cap dropped valid dashboard deployments.

Rejected: remove the length cap entirely | a bounded header budget is still a conservative validation guard.

Confidence: high

Scope-risk: narrow

Directive: Keep prefix validation centralized in hermes_cli.dashboard_auth.prefix so auth routes, cookies, and SPA asset rewriting agree.

Tested: python probe for the 73-char HA ingress prefix; scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_prefix.py -q; .venv/bin/python -m pytest tests/hermes_cli/test_web_server.py -k 'spa_assets_are_read_as_utf8' -q; python -m ruff check hermes_cli/dashboard_auth/prefix.py tests/hermes_cli/test_dashboard_auth_prefix.py; git diff --check

Not-tested: full test suite
2026-07-06 03:18:02 -07:00
Teknium
3b5c645433 fix(cron): durable run-claim for one-shots instead of a fixed +60s advance
The +60s next_run_at advance only delayed a duplicate one-shot dispatch by
one tick — a job that outlives the 60s tick interval (the reported 2.5-min
research prompt) still re-fired on the next tick after the window expired,
so the concurrent gateway+desktop double-delivery persisted.

Replace it with a durable run_claim (at+by, mirroring fire_claim) stamped
on the one-shot under the same jobs lock get_due_jobs holds, and checked at
the top of the due-scan: a fresh claim held by an in-flight run makes every
other scheduler process skip the job for its ENTIRE run, not one tick.
mark_job_run() clears the claim on completion; a ONESHOT_RUN_CLAIM_TTL
(30 min) safety valve re-dispatches a claim left by a tick that died mid-run
so a one-shot is never wedged.

E2E: long-running one-shot no longer double-fires at +28/+61/+120/+179s;
completion clears the claim + disables the job; crash recovery re-arms past
the TTL. +3 regression tests.
2026-07-06 03:17:21 -07:00
isheng
06cc983b86 fix(cron): prevent double-execution of one-shot jobs across concurrent schedulers
When two scheduler processes (gateway + desktop) run concurrently,
both could pick up the same one-shot job from get_due_jobs() because
its next_run_at was not advanced before execution started — only
recurring jobs were advanced (L3446).  This caused duplicate deliveries
and wasted token spend (#59229).

Now _get_due_jobs_locked advances a one-shot's next_run_at by 60s
before returning it as due, persisted immediately under the same
file lock.  mark_job_run re-anchors next_run_at on completion, so a
tick death between advance and execution only delays the job by one
tick window — it is never lost.

Closes #59229
2026-07-06 03:17:21 -07:00
mbac
f3af7930c2 fix(tui_gateway): honor launch profile terminal.cwd for dashboard chat
Dashboard /chat for the default (launch) profile attaches to the
dashboard process's in-memory TUI gateway. The Node PTY child receives a
bridged TERMINAL_CWD env var, but the in-memory gateway process does not,
so cwd resolution fell through to os.getcwd() (wherever `hermes
dashboard` was launched) and ignored the configured terminal.cwd.

Read the launch profile's config.yaml directly in the in-memory cwd
resolution: a configured terminal.cwd now wins over a stale process env
and the launch directory. Widened to the resume/fallback session-cwd
sites (not just _completion_cwd) via a shared _default_session_cwd()
helper so fresh AND resumed sessions honor the config.

Co-authored-by: ygd58 <buraysandro9@gmail.com>
2026-07-06 03:16:28 -07:00