Commit graph

17718 commits

Author SHA1 Message Date
teknium1
75e0d52034 fix(windows): sweep remaining bare read_text/write_text sites + linter rule
AST-driven pass over every Path.read_text()/write_text() without an
explicit encoding= across non-test code: 71 sites in 34 files
(skills_hub, hermes_cli/main+profiles+service_manager+container_boot,
mem0/hindsight/honcho plugins, achievements dashboard, release/CI
scripts, productivity+comfyui skill helpers, agent/*). Verified zero
positional-encoding collisions before insertion; per-file compile()
check after.

Adds a check-windows-footguns rule flagging bare single-line
read_text/write_text (multi-line forms stay covered by the AST guard
test from #38985). Together with the salvaged contributor commits this
retires the ~169-site bare file-I/O class (#37423's long tail).
2026-07-24 17:10:39 -07:00
solyanviktor-star
adecb0d1a9 fix(skills): read OOXML parts as bytes and form JSON as UTF-8 in office skill scripts
The bundled office skills (#68595) read user documents and agent-authored
payloads with the locale-default codec:

- docx/powerpoint validators/base.py opened OOXML part XML in text mode
  before handing it to lxml. On Windows (cp1251/GBK) the bytes decode to
  mojibake that lxml then parses, so validation runs against silently
  corrupted document text; on locales where the UTF-8 bytes don't decode
  the validator crashes with UnicodeDecodeError instead of validating.
  Opening as bytes lets lxml honor the encoding declared in the XML prolog.

- The pdf form scripts (fill_fillable_fields, fill_pdf_form_with_annotations,
  create_validation_image, check_bounding_boxes) read the fields JSON the
  agent authors — UTF-8 by construction — with the locale codec, so
  non-ASCII form values (any Cyrillic/CJK/accented input) either crash or
  get written into the user's PDF as mojibake. The json.dump writers use
  ensure_ascii=True and were already safe; only the readers needed pinning.

Adds a contract test asserting every document/payload reader is
locale-independent, plus a live regression test that runs
check_bounding_boxes.py on a non-ASCII fields.json under a forced
non-UTF-8 locale — it fails without the fix on both POSIX (C locale)
and Windows (cp1251 chokes on the 0x98 byte of U+2018).
2026-07-24 17:10:39 -07:00
solyanviktor-star
607f647141 fix(cli): read .worktreeinclude and .gitignore as UTF-8 in worktree setup
_setup_worktree read both files with the locale default encoding. On a
cp1251/GBK Windows machine a UTF-8 include list either decodes to
mojibake paths (non-ASCII entries silently not copied) or raises
UnicodeDecodeError, which the enclosing handler logs at DEBUG and
swallows — no include is copied at all, so the worktree starts without
.env/keys and the agent breaks invisibly. A Notepad BOM likewise glues
to the first include entry on every platform, and to the first
.gitignore line, defeating the '.worktrees/' membership check and
appending a duplicate entry on each run.

Read both files with utf-8-sig + errors=replace, matching the canonical
.env readers in hermes_cli/config.py (utf-8-sig because Notepad adds a
BOM) and the UTF-8 append this same block already performs on
.gitignore.

Regression tests exercise the real cli._setup_worktree: the two BOM
tests fail without the fix on any platform, the non-ASCII include test
additionally reproduces the Windows locale failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:10:39 -07:00
PRATHAMESH75
6e30aa2a3c fix(skills): tolerate non-UTF-8 bytes in hub lock.json
_read_hub_installed_names() reads ~/.hermes/skills/.hub/lock.json with a
strict utf-8 decode. Hub skill descriptions can carry Windows-1252
typographic bytes (em-dash 0x97, smart quotes, bullets) as single high
bytes; read_text(encoding="utf-8") then raises UnicodeDecodeError, which
is a ValueError sibling not caught by the function's
except (OSError, json.JSONDecodeError). It escapes and 500s the whole
/api/skills endpoint, blanking the desktop Skills panel.

Decode with errors="replace" so the offending byte degrades to U+FFFD
and the structurally valid JSON — and every other skill — stays readable.

Fixes #68053
2026-07-24 17:10:39 -07:00
AlexFucuson9
de9d480413 fix: add UTF-8 encoding to read_text/write_text in tools/ and agent/
Path.read_text() and Path.write_text() without encoding= default to the
system locale (cp1252 on Windows), which corrupts non-ASCII JSON content.

Coverage-gap fix for files not addressed by prior encoding PRs:
- tools/skills_hub.py: 6 read_text + 8 write_text (cache, index, lock files)
- tools/skills_sync.py: 1 read_text (lock file)
- tools/xai_http.py: 1 read_text + 1 write_text (auth store, marker)
- agent/shell_hooks.py: 1 read_text (allowlist)
- gateway/status.py: 1 read_text (PID file)
- hermes_cli/banner.py: 1 read_text + 1 write_text (update cache)

All sites read/write JSON or short text. No behavioral change on Linux
(already UTF-8); fixes silent data corruption on Windows.
2026-07-24 17:10:39 -07:00
solyanviktor-star
40828997a5 fix(profile): read .env as utf-8-sig in the distribution-install preview
`_render_distribution_plan` reads the target profile's `.env` to decide
whether a required env var is already set (so it doesn't nag the user),
using `Path.read_text()` with no encoding. Two bugs:

1. `Path.read_text()` defaults to the system locale (cp1251/GBK on Windows),
   which raises `UnicodeDecodeError` on any non-ASCII byte. The surrounding
   `except OSError` does NOT catch that — `UnicodeDecodeError` is a
   `ValueError` — so a mis-encoded `.env` aborts the entire install preview.
2. Even on a UTF-8 locale, a Notepad-added BOM prefixes the first key
   (`KEY`), so the very first required env var is mis-reported as
   "needs setting" when it is actually present.

`.env` is written as UTF-8 everywhere in the codebase. Read it as
`utf-8-sig` (tolerates the BOM) and also catch `UnicodeDecodeError` so a
genuinely un-decodable file skips the pre-check instead of crashing.

Regression tests: a BOM-prefixed `.env` whose first key must still read as
"set", and an invalid-UTF-8 `.env` that must not abort the preview.
2026-07-24 17:10:39 -07:00
solyanviktor-star
f1ea4a56c2 fix(memory): cover the remaining setup-time .env reads with utf-8-sig
Follow-up to review feedback:

- mem0 _prompt_api_key read .env with the locale default, so a Notepad
  BOM hid the first key from the masked current-value lookup; read it
  with utf-8-sig + errors=replace like the canonical readers in
  hermes_cli/config.py.
- hindsight _load_simple_env used plain utf-8; it also parses the Hermes
  .env during post_setup, where a BOM stuck to the first key. Switch to
  utf-8-sig + errors=replace.
- Add hindsight regressions: BOM key matching in _load_simple_env and in
  the cloud post_setup writer, plus non-ASCII round-trip preservation,
  and a mem0 regression for the BOM'd masked-key lookup. The BOM tests
  fail without the fix on any platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:10:39 -07:00
solyanviktor-star
75afc47baa fix(memory): read/write .env as UTF-8 in mem0 and hindsight setup
The mem0 and hindsight memory-provider setup routines round-trip the
user's ~/.hermes/.env: they read existing lines, update the keys they
manage, and rewrite the whole file preserving every other line verbatim.
Both used env_path.read_text() / write_text() with no encoding.

read_text()/write_text() with no encoding fall back to the system locale
(cp1252/GBK on Windows), so on a non-UTF-8 host the preserved lines get
mangled or the call crashes on any non-ASCII value, and — because the
reader never strips a BOM — a Notepad-edited .env makes the first key
fail the in-place match and get duplicated instead of updated.

Match the canonical .env readers in hermes_cli/config.py: read with
encoding='utf-8-sig' (BOM-tolerant) and write with encoding='utf-8'.
mem0/_setup.py already pins utf-8 for mem0.json, so this just aligns the
.env path in the same file. Fixes both memory plugins in one class fix.

Adds regression tests: a BOM'd .env updates the first key in place
(locale-independent, fails without the fix) and non-ASCII existing lines
survive the round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:10:39 -07:00
jeay
e02170f54a fix(hindsight): specify UTF-8 encoding for file I/O on Windows
On Windows with CJK locales (e.g. Chinese/GBK), pathlib.Path.read_text()
defaults to the system encoding instead of UTF-8, causing UnicodeDecodeError
when reading .env or .json config files that contain non-ASCII characters.

Explicitly pass encoding='utf-8' to all read_text() and write_text() calls
in the hindsight memory provider plugin.
2026-07-24 17:10:39 -07:00
AlexFucuson9
411a686de2 fix: add encoding="utf-8" to Path.write_text() calls (P1)
Path.write_text() without encoding defaults to system locale encoding.
On Windows (cp1252), this silently corrupts non-ASCII content written
to JSON files, config files, and cache files.

This is the write-side counterpart to the read_text() encoding fix
(PR #56115). PLW1514 only covers open() calls — Path methods are
unguarded by ruff.

39 instances across 16 files, all passing py_compile.

Files changed:
- agent/copilot_acp_client.py (1)
- tools/web_tools.py (1)
- tools/xai_http.py (1)
- tools/skills_hub.py (8)
- gateway/slash_commands.py (1)
- gateway/run.py (5)
- gateway/dead_targets.py (1)
- gateway/delivery.py (2)
- gateway/platforms/qqbot/adapter.py (1)
- hermes_cli/gateway.py (1)
- hermes_cli/banner.py (1)
- hermes_cli/service_manager.py (5)
- hermes_cli/container_boot.py (5)
- hermes_cli/uninstall.py (1)
- hermes_cli/main.py (2)
- hermes_cli/profiles.py (3)
2026-07-24 17:10:39 -07:00
kael-odin
44649e69f4 test(install): add UTF-8 regression guard for skills_sync child path
Addresses hermes-sweeper review on PR #54866: the installer runs
tools/skills_sync.py as a child python.exe whose PYTHONIOENCODING /
PYTHONUTF8 the scoped install.ps1 block sets, but there was no
regression test for this child-Python UTF-8 path. The existing
test_child_process_inherits_utf8_mode covers a different (bootstrap
entry-point) flow.

Add TestSkillsSyncUtf8Guard: three subprocess tests that import
skills_sync (triggering its import-time stdout/stderr reconfigure)
and assert the checkmark/up-arrow glyphs the script prints at
tools/skills_sync.py:596,675 emit valid UTF-8 and exit 0 even when
the child env is left unset or explicitly hostile (gbk). A third
test proves the guard is load-bearing by reproducing the crash
without it.

Also keep the new install.ps1 comment ASCII-only (the checkmark
spelled out as U+2713) per the file's PS 5.1 parser-compatibility
contract at scripts/install.ps1:79-80; the literal glyph in the
comment violated that contract.
2026-07-24 17:10:39 -07:00
kael-odin
3f1de1fc4a fix(install): emit UTF-8 from skills_sync on non-UTF-8 Windows locales
On Windows with a non-UTF-8 system locale (e.g. CP936/GBK on zh-CN),
Python defaults stdout/stderr to the active codepage. tools/skills_sync.py
prints glyphs such as checkmark (U+2713) and up-arrow (U+2191) that GBK
cannot encode, raising UnicodeEncodeError mid-run.

The installer (scripts/install.ps1) captures this script's stdout and the
Rust bootstrap parses it as UTF-8 expecting a JSON result frame. A GBK
byte stream (or the traceback it triggers) surfaces as:

  WARN stdout read error: stream did not contain valid UTF-8
  stage=config-templates state=Failed
  error=install.ps1 -Stage config-templates produced no JSON result frame
        (exit=Some(0))

i.e. the stage fails even though the script exits 0. install.ps1 already
sets [Console]::OutputEncoding = UTF8, but that does not propagate to the
python.exe child (Python reads PYTHONIOENCODING / locale, not the console
encoding).

Fix in two places for defense in depth:
- tools/skills_sync.py: reconfigure sys.stdout/stderr to UTF-8 at import so
  output is valid UTF-8 regardless of caller or active codepage.
- scripts/install.ps1: set PYTHONIOENCODING=utf-8 and PYTHONUTF8=1 (scoped
  to the call, restored afterwards) around the skills_sync.py invocation.
2026-07-24 17:10:39 -07:00
AlexFucuson9
cf35fd6de5 fix(core,cli,gateway,plugins): add encoding='utf-8' to read_text() calls
Path.read_text() without an explicit encoding uses the platform's
default encoding. On Windows this is typically cp1252 or mbcs, which
causes UnicodeDecodeError or silent data corruption when reading
UTF-8 content (JSON files, user text, config with non-ASCII chars).

This is the read-side companion to the write_text() encoding fix.
Fixed the most critical locations that read JSON data, user content,
and config files across 14 files with 31 call sites.

Pattern: .read_text() → .read_text(encoding='utf-8')
         json.loads(path.read_text()) → json.loads(path.read_text(encoding='utf-8'))
2026-07-24 17:10:39 -07:00
annguyenNous
efaba061fb fix(cli): add explicit encoding to read_text/write_text calls
Path.read_text() and Path.write_text() without explicit encoding
default to the system locale encoding. On Windows this is typically
cp1252, which causes UnicodeDecodeError for UTF-8 content (JSON
configs, user data, service scripts).

Add encoding="utf-8" to all read_text() and write_text() calls
across 8 CLI files, matching the pattern established in PR #50534
(security_audit_startup.py) and ruff rule PLW1514.

Fixed files:
- main.py: 4 read_text calls
- auth.py: 3 read_text calls
- banner.py: 1 read_text + 1 write_text
- service_manager.py: 1 read_text + 4 write_text
- container_boot.py: 1 read_text + 4 write_text
- doctor.py: 3 read_text calls
- uninstall.py: 2 read_text calls
- gateway.py: 1 write_text call
2026-07-24 17:10:39 -07:00
CoreyNoDream
9f6b2a64e0 fix: decode config and state files as UTF-8 on non-UTF-8 locales
Several file-I/O call sites still use open() / Path.read_text() /
Path.write_text() without an explicit encoding, so they fall back to
the platform default. On Windows CN/JP/KR locales (GBK/CP932/CP949)
any non-ASCII byte in a config/state/user-content file raises
UnicodeDecodeError or UnicodeEncodeError and crashes the caller.

to the remaining hot paths:

- agent/copilot_acp_client.py:  fs/read_text_file and fs/write_text_file
                                (Copilot's read_file / write_file tools,
                                 directly reported in #18637 bug 2)
- agent/model_metadata.py:      context-length YAML cache load + two
                                save sites (context probing is on the
                                call path of every model invocation)
- agent/nous_rate_guard.py:     cross-session rate-limit JSON state
                                (read + atomic write via os.fdopen)
- cron/scheduler.py:            user config.yaml read in run_job
- gateway/delivery.py:          cron output writes for AI-generated
                                content, very likely non-ASCII

yaml.dump call sites also gain allow_unicode=True so the emitted
YAML preserves non-ASCII chars as-is instead of emitting \u escape
sequences.

Adds regression tests that monkeypatch builtins.open / Path.read_text
/ Path.write_text to simulate a GBK locale: each test raises
UnicodeDecodeError / UnicodeEncodeError unless the caller explicitly
passes encoding='utf-8'. Verified that the tests fail on main and
pass with this change, on Linux as well as on Windows.

Refs #18637
2026-07-24 17:10:39 -07:00
chancelu
049af61d64 fix: handle non-UTF-8 files in OpenClaw migration script 2026-07-24 17:10:39 -07:00
Tianworld
60dbce7c34 fix(doctor): UTF-8/latin-1 fallback when scanning .env
Prefer UTF-8 for ~/.hermes/.env provider scans, then latin-1 for cp1252/Notepad files. Add regression test for invalid UTF-8 bytes.
2026-07-24 17:10:39 -07:00
teknium1
8dc9978741 test: update credential-refresh tests for retire-not-close contract
The three refresh tests asserted the replaced shared client gets
close()d — the exact cross-thread close #70773 removes. They now pin
the new contract: close() is NOT called from the refresh path; the
old client is retired (sockets shutdown, FD release deferred to GC).
2026-07-24 16:04:48 -07:00
teknium1
1608a46884 fix(agent): retire replaced shared OpenAI clients instead of cross-thread pool close
Widen the #70773 fix beyond the three in-request cleanup sites removed in
the cherry-picked commit: every remaining path that swaps out the shared
OpenAI client could still hard-close its pool from a thread that doesn't
own the in-flight sockets (credential rotation/refresh on the turn thread,
dead-connection cleanup, gateway cache eviction, transport recovery) —
the same FD-recycle corruption vector, just rarer.

Add AIAgent._retire_shared_openai_client(): shutdown(SHUT_RDWR) all pooled
sockets (FD-safe from any thread, unblocks in-flight readers) but never
call client.close() — FD release is deferred to GC, which cannot run until
every borrowing thread has unwound its SSL BIO. Refcounting is the
ownership handshake; with no borrowers the FDs are released immediately.

Wired into:
- _replace_primary_openai_client (rotation/refresh/dead-conn cleanup)
- try_recover_primary_transport (primary_recovery)
- release_clients (gateway cache_evict)

agent.close() keeps the hard close: full teardown is a real session
boundary where no request may be in flight.

Tests: new tests/run_agent/test_70773_shared_client_fd_corruption.py
covers the three watchdog/retry sites plus retire semantics; existing
close-assertions updated to pin retire-not-close.
2026-07-24 16:04:48 -07:00
JonthanaHanh
e51abb266d fix(agent): prevent shared OpenAI client FD-recycle corruption from stale stream watchdog
The streaming stale watchdog was calling
_replace_primary_openai_client() from its polling thread, which closes
the shared client's connection pool. Worker threads from previous
stale-killed attempts may still be unwinding their SSL BIOs, causing
TLS application-data to overwrite SQLite file headers via FD reuse.

This is the same corruption vector documented in #67142 for Anthropic,
where the fix was to never close the shared client from a non-owner
thread. Apply the same pattern to the OpenAI-wire path:

- Stale stream watchdog: skip shared client replacement
- Mid-tool-retry cleanup: skip shared client replacement
- Stream retry cleanup: skip shared client replacement

The request-local client is already closed via _close_request_client_once.
The shared client is replaced lazily by _ensure_primary_openai_client
on the next request, which runs on the owning thread.

Closes #70773.
2026-07-24 16:04:48 -07:00
joaomarcos
66cc0075a2 fix(desktop): satisfy eslint import-order rules in use-composer-draft.test.tsx
CI's check:lint failed on two perfectionist rule violations introduced by the
new test file: type import ordering and missing blank line between the
parent-relative and same-directory import groups. No behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 16:04:12 -07:00
joaomarcos
d083d9dacd fix(desktop): close cross-session leak windows in composer + session refs (#59305)
Two React passive-effect timing bugs let a session switch land in the wrong
chat: activeSessionIdRef/selectedStoredSessionIdRef (use-session-state-cache)
and the composer's attachment-scope swap (use-composer-draft) both mirrored
their source props via useEffect, which fires one commit AFTER the new
session's view has already painted — a synchronous read/submit in that window
observed the outgoing session's ids/attachments.

- use-session-state-cache.ts: mirror the session refs synchronously during
  render instead of a useEffect, guarded to fire only when the prop itself
  changed (not unconditionally) so an imperative pin from submit.ts /
  use-session-actions (e.g. a freshly resumed runtime id, intentionally not
  synced to the source atom) survives an unrelated re-render.
- use-composer-draft.ts: the per-thread attachment-scope-swap effect is now a
  useLayoutEffect, closing the window before paint.
- submit.ts / session-context-drift.ts: add a 3rd drift prong comparing the
  composer's loaded scope (SubmitTextOptions.composerScope) against the
  submit target, resolved into the same lineage-root domain
  (resolveComposerSessionKey) the composer itself uses — comparing against
  the raw tip id would false-positive-abort every submit into any session
  that has ever auto-compressed.
- routes.ts / chat/index.tsx: the primary composer's durable scope key now
  prefers the route over a possibly-stale store selection
  (primaryRouteSelectedSessionId).
- use-composer-draft.ts: redacted [composer-rehydrate] diagnostic log
  (counts/kinds/scope only, never raw refs) for future reports in this class.
- chat-runtime.ts: normalize attachment id values (url/path) before hashing
  so a re-attach with a trailing slash or backslash path dedupes correctly.

16 files, 286 tests across the touched/dependent suites (17 files) green,
including new regression coverage for each fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 16:04:12 -07:00
teknium1
86084d03ba fix: getattr-guard _stop_loop_liveness_guards in GatewayRunner.stop
Teardown-path tests build bare runners via object.__new__ without
the liveness-guard machinery; the unguarded call raised
AttributeError in 8 tests. Same guard pattern as the start path.
2026-07-24 16:03:42 -07:00
Hermes Agent
9cd7296849 refactor(gateway): gate loop-liveness watchdog via config.yaml, drop HERMES_* env knobs
Follow-up to the salvaged #69164 commits: policy forbids introducing new
HERMES_* environment variables, so the four watchdog env knobs
(HERMES_GATEWAY_LOOP_WATCHDOG / _INTERVAL / _TIMEOUT / _STRIKES) are
replaced with a single config.yaml boolean:

  gateway:
    loop_watchdog: true   # default; false disables both guards

- gateway/config.py: new GatewayConfig.loop_watchdog field (default True),
  parsed from top-level or nested gateway: form, round-trips via
  to_dict/from_dict.
- gateway/run.py: _start_loop_liveness_guards() checks config.loop_watchdog
  before arming the floor timer + watchdog (getattr-guarded for bare
  object.__new__ runners).
- gateway/shutdown_watchdog.py: start_loop_liveness_watchdog() no longer
  reads the environment; probe interval/timeout/strikes are module
  constants (30s/10s/3 — ~90s to restart, matching the systemd watchdog
  layer's posture).
- hermes_cli/config.py: documented gateway.loop_watchdog default so
  'hermes config set gateway.loop_watchdog false' validates.
- tests: env-knob tests replaced with config-gate + round-trip tests;
  the final-strike boundary test injects its probe via max_strikes
  directly instead of patching the removed env helper.
2026-07-24 16:03:42 -07:00
Josh Tsai
df03120cb6 fix(gateway): recheck stop immediately before watchdog hard exit
- A stop() landing while the final diagnostics (critical log,
  traceback dump) are executing could still reach os._exit(75) after
  the pre-diagnostic check. Add a third stop_event recheck immediately
  before the hard exit: diagnostics may complete, but a disarmed
  watchdog never exits.
- Deterministic regressions for both windows (stop triggered from
  inside logger.critical and from inside faulthandler.dump_traceback);
  mutation-verified (removing the check turns both red). Frozen-loop
  semantics unchanged.

Addresses the second round of the shutdown-race review on #69164.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:03:42 -07:00
Josh Tsai
aff415b653 fix(gateway): close watchdog shutdown race against final-strike exit
- Re-check stop_event after a missed probe (before the strike
  increment) and again on entering the final-strike branch (before the
  critical log, dump, and hard exit), so a normal stop() landing
  between the last timeout check and the exit path can no longer be
  misclassified as a freeze and trigger a supervisor restart.
- Deterministic boundary tests pin both re-checks independently
  (mutation-verified: removing either check turns its own test red);
  frozen-loop semantics are unchanged.

Addresses the shutdown-race review on #69164.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:03:42 -07:00
Josh Tsai
7af3a6fc7c fix(gateway): detect and escape silent event-loop freezes
- A self-rescheduling 5s call_later floor timer, armed before any
  adapter connects, guarantees the selector always has a finite
  timeout, so the existing async defenses (polling heartbeat, timeout
  guards) regain a chance to run after a zero-pending-timer stall.
- A resident daemon-thread liveness watchdog probes the loop via
  call_soon_threadsafe every 30s; after 3 consecutive 10s-timeout
  misses (~120s of total unresponsiveness) it dumps all thread
  tracebacks and exits with the established
  GATEWAY_SERVICE_RESTART_EXIT_CODE (75) so a supervisor restarts the
  gateway - async-level recovery cannot run on a frozen loop.
- stop() disarms both guards before any teardown await so a busy
  shutdown is never misjudged as a freeze.
  HERMES_GATEWAY_LOOP_WATCHDOG=0 disables; _INTERVAL/_TIMEOUT/_STRIKES
  tune the thresholds.

Fixes #69089

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:03:42 -07:00
teknium1
dee0b5bf69 fix: gate SIGUSR2 faulthandler registration behind POSIX check
signal.SIGUSR2 and faulthandler.register() don't exist on Windows;
the bare reference raised AttributeError at import time per the
windows-footgun checker. faulthandler.enable() still covers
fatal-error dumps on all platforms.
2026-07-24 16:03:10 -07:00
Shannon Sands
eb2f5b5723 fix(gateway): stay alive on mixed retryable + non-retryable startup failures
When connected_count == 0 and at least one platform failed with a
non-retryable error, the runner exited with GATEWAY_FATAL_CONFIG_EXIT_CODE
(78) even if OTHER platforms failed for merely transient reasons.

Real-world shape (NS-609, hosted instance): WhatsApp enabled but never
paired (non-retryable whatsapp_not_paired) + Telegram TimedOut during
polling startup (retryable) => exit 78 => the gateway either goes
permanently down (supervisors honoring the exit-78 contract via
RestartPreventExitStatus / the s6 finish->125 translation from #51228) or
crash-loops (anything else). Either way Telegram never gets its retry and
the dashboard drops with every exit, so a single unpaired platform plus
one network blip disconnected every channel on the instance.

Now exit 78 is reserved for the case where ALL startup failures are
non-retryable (true config error, nothing to wait for). With mixed
failures the gateway stays alive in degraded state: the reconnect watcher
recovers the retryable platforms and the misconfigured ones stay
fatal-parked and visible in runtime status.
2026-07-24 16:03:10 -07:00
kshitijk4poor
7a62f62197 fix: explicit encoding for faulthandler file open (ruff PLW1514) 2026-07-24 16:03:10 -07:00
webtecnica
83fe362e80 fix(gateway): prevent reconnect watcher wedge after network-loss fatal error (#70344)
Three-part fix for the gateway going silently deaf after a retryable
fatal adapter error (e.g. httpx.ConnectError on Telegram):

1. **Detach-on-timeout in _connect_adapter_with_timeout** — Replaced
   plain asyncio.wait_for with the task-detach pattern used by
   _await_adapter_cleanup_with_timeout. asyncio.wait_for cancels the
   overdue task but then waits for it to exit, so a connect() that
   catches CancelledError can block recovery forever. The detach
   pattern releases the runner at the deadline via
   consume_detached_task_result.

2. **Ensure reconnect watcher always runs after escalation** — Added
   _ensure_reconnect_watcher_running(), called after queueing a
   retryable fatal error. If the reconnect watcher task has died
   (exhausted restart budget, terminal exception), it is respawned
   so queued platforms are never permanently stranded.

3. **Faulthandler at gateway startup** — Enabled faulthandler +
   SIGUSR2 dump to a rotating file under HERMES_HOME/logs/ for
   post-mortem diagnosis of future event-loop freezes.

Tests added for _ensure_reconnect_watcher_running (alive, dead,
not-started, not-running), fatal-error integration (retryable calls
ensure, non-retryable does not), and _connect_adapter_with_timeout
(timeout raises, success returns).
2026-07-24 16:03:10 -07:00
teknium1
9c65cdb043 test: accept the new profile-scoped kwargs in status fakes
/api/status?profile= now passes pid_path=/path=/expected_home= to the
PID and runtime-status readers; the profile-unification fakes had
zero-arg signatures and raised TypeError. Plain /api/status call shapes
are unchanged (pinned by the existing zero-arg tests in
test_web_server.py).
2026-07-24 16:02:39 -07:00
teknium1
ddb86725b5 test(web): pin per-profile gateway state scoping on /api/status
Follow-up for the salvaged #70498 fix: replace the original PR's
mock-signature churn (28 lambda **kw edits, needed only because it changed
the no-profile call shape) with two targeted regression tests:

- ?profile=<name> must pass the profile's gateway.pid / gateway_state.json
  paths and expected_home to the gateway status readers (HOME-anchored
  per-profile state under ~/.hermes/profiles/<name>/)
- ?profile=<unknown> must 404 via _resolve_profile_dir

The production change keeps plain /api/status on the exact zero-arg calls,
so every pre-existing test passes unmodified.
2026-07-24 16:02:39 -07:00
webtecnica
c9c9b17d98 fix(web): resolve per-profile gateway state for ?profile= in /api/status
When ?profile=<name> was passed to /api/status, the handler used
_config_profile_scope to set the HERMES_HOME contextvar override, but the
gateway liveness check (get_running_pid_cached) and runtime status read
(read_runtime_status) both resolve _get_process_hermes_home(), which
deliberately ignores contextvar overrides (issue #56986) — it always reads
os.environ['HERMES_HOME'] or the platform default. A named profile's
gateway identity files (~/.hermes/profiles/<name>/gateway.pid,
gateway_state.json) were therefore never found and the endpoint always
reported the profile's gateway as stopped.

Fix: when ?profile=<name> is requested, resolve the profile directory and
pass explicit profile-scoped paths:
- get_running_pid_cached(pid_path=profile_dir / 'gateway.pid')
- read_runtime_status(path=profile_dir / 'gateway_state.json')
- get_runtime_status_running_pid(..., expected_home=profile_dir)

This is the same explicit-path pattern _collect_profile_gateway_topology
already uses for per-profile gateway state, and it works within the #56986
constraint (no HERMES_HOME env mutation; read-only cross-profile access).
Plain /api/status without ?profile= keeps the exact zero-arg calls, so its
behavior — including the pid-cache signature and runtime-status fallback —
is byte-for-byte unchanged.

Fixes #69143
2026-07-24 16:02:39 -07:00
Eugeniusz Gilewski
6fba781945 fix(config): preserve opaque .env values
The .env sanitizer inferred missing newlines from known KEY= substrings
inside existing values. Plain secrets containing those bytes could therefore
be split into synthetic assignments and rewritten to disk.

Treat each physical line as the only assignment boundary and keep bytes after
the first equals sign opaque for boundary discovery. Preserve safe formatting,
null-byte removal, BOM handling, and normal one-assignment-per-line parsing.

Cover direct loading, dotenv loading, sanitization, writers, and migration
with behavioral regressions.

Fixes #29155
2026-07-24 16:02:08 -07:00
teknium1
18af81bb5b fix(caching): reconstruct static system prefix on session restore and post-compression reuse
Follow-up to the cherry-picked #68258 base: the cross-session-stable
prefix (_cached_system_prompt_static) was only recorded on fresh
builds, so two paths silently degraded to the legacy single-breakpoint
layout (flagged in review of #68258/#69341/#69704):

- Session restore: gateway surfaces build a fresh AIAgent per turn and
  restore the persisted prompt verbatim from the session DB; the static
  prefix stayed None from turn 2 onward, flip-flopping the wire layout.
- Post-compression cached-prompt reuse: _invalidate_system_prompt()
  clears the static prefix, and the keep-cached-prompt branch never
  restored it.

Both sites now reconstruct the stable tier and adopt it ONLY when the
authoritative prompt string literally startswith() it — stable-tier
drift (skills edited, identity changed) falls back to the legacy layout
with the stored bytes untouched. Fail-open on any builder error. The
restore-path rebuild is gated on _use_prompt_caching so non-Anthropic
routes skip it entirely.

Refs #68191

Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com>
Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
2026-07-24 16:01:38 -07:00
Drexuxux
fb1b89b09e fix(prompt-caching): inject cache breakpoints after message normalization
The conversation loop normalizes message text right before the API call so
the request prefix is byte-identical across turns -- the stated reason is
KV cache reuse on local inference servers and better cache hit rates on
cloud providers. Cache breakpoints were injected *before* that pass, which
defeats it.

`_apply_cache_marker` rewrites a plain-string `content` into a
`[{"type": "text", ...}]` block. The normalization pass is guarded on
`isinstance(content, str)`, so every message that just got marked is
silently skipped by it and keeps its raw leading/trailing whitespace. A
message is only marked while it sits in the last-3 window, so:

    turn N      in the window  -> marked, content "file1\nfile2\n"
    turn N+1    rolled out     -> plain,  content "file1\nfile2"

The same logical message is sent with different bytes on consecutive
turns. The prefix stops matching at that position -- which is inside the
span the breakpoints were placed to protect -- so the reusable prefix
collapses back toward the system breakpoint on every turn. Tool results
carry a trailing newline almost by default (any shell command output), so
this is the common case, not an edge case.

Move the injection below every message mutation. Besides fixing the
whitespace divergence this stops breakpoints from being spent on messages
that the orphan sweep or the thinking-only drop is about to remove or
merge away -- a marker on a dropped message is a wasted breakpoint out of
the four available.

Nothing between the old and new call sites reads `cache_control`, and the
mutators now see the plain-string shapes they were written against.
2026-07-24 16:01:38 -07:00
konsisumer
9fdadf0cd7 fix(agent): cache static system prompt prefixes 2026-07-24 16:01:38 -07:00
teknium1
6c14b12d1f fix(checkpoints): bind an empty orphan preview to an empty deletion allowlist
Follow-up for salvaged PR #69141, addressing the last open review point:
cmd_prune() only set orphan_allowlist inside 'if orphans or pre_v2_orphans',
so a zero-orphan preview passed the unrestricted None sentinel down to
prune_checkpoints(), authorizing deletion of any project that became
orphaned between the preview and the rescan — with zero confirmation
calls. The allowlist is now bound unconditionally for every non-force
run (empty preview => empty allowlist); --force keeps None. Adds the
zero-orphan-preview timing regression plus allowlist-identity tests.
2026-07-24 16:01:06 -07:00
joaomarcos
c004c74df6 doc(checkpoints): update infographic to show 8 files 2026-07-24 16:01:06 -07:00
joaomarcos
ac70af0395 doc(checkpoints): add cyberpunk infographic for startup sweep safety 2026-07-24 16:01:06 -07:00
joaomarcos
a373fab1ce fix(checkpoints): bind orphan confirmation to previewed identities
Address P1 from PR review: cmd_prune()'s y/N preview reads
store_status() but the confirmed deletion re-scans both the v2 and
pre-v2 layouts from scratch. A workdir that goes missing while the
human is answering the prompt gets swept in as if it had been shown
and approved.

prune_checkpoints() now accepts orphan_allowlist — a set of v2 project
hashes and/or pre-v2 shadow repo paths. When set, only orphans whose
identity is in the set are deleted; anything newly orphaned since the
scan survives the run. cmd_prune() builds this set from the exact
projects it just displayed and passed confirmation for. --force still
passes None (no preview shown, so nothing to bind to).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 16:01:06 -07:00
joaomarcos
3ad8552f78 test(checkpoints): cover prune decline/accept/--force for pre-v2-only and mixed stores
Requested by egilewski on #69141: the orphan confirmation flow had no
test coverage at all before this. Exercises hermes_cli.checkpoints.cmd_prune
directly against pre-v2-only and mixed (v2 + pre-v2) fake stores —
decline aborts with nothing deleted, accept deletes both layouts,
--force and --keep-orphans skip the prompt as expected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 16:01:06 -07:00
joaomarcos
dd8c0c7be8 fix(checkpoints): include pre-v2 shadow repos in orphan preview
store_status()["projects"] only ever covered v2 metadata, so the
`hermes checkpoints prune` confirmation prompt was blind to pre-v2
base/<hash>/HEAD shadow repos that prune_checkpoints() deletes
separately via shutil.rmtree — a pre-v2-only or mixed store could
lose checkpoint history without ever hitting the confirmation.

Extract the pre-v2 scan into _pre_v2_shadow_repos() and have both
store_status() (preview, new pre_v2_projects key) and
prune_checkpoints() (deletion) read from it, so the CLI prompt can
no longer diverge from what actually gets removed.

Addresses review from egilewski on #69141.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 16:01:06 -07:00
joaomarcos
cd3f653b4e fix(checkpoints): never auto-delete orphans on unattended startup sweep
Builds on this PR's diagnosis by @Frowtek: a missing workdir is
ambiguous (deleted project vs. an unmounted external volume / network
share / VPN not yet up), so it's not safe evidence for a destructive
GC sweep — especially one that runs unattended at startup.

- cli.py / gateway/run.py: the startup auto-maintenance sweep now
  always passes delete_orphans=False to maybe_auto_prune_checkpoints().
  It still prunes by retention_days, size cap, and legacy archives —
  none of which require guessing whether a project was deleted or is
  just temporarily unreachable.
- hermes_cli/config.py: drop the now-unused delete_orphans default.
- hermes_cli/checkpoints.py: `hermes checkpoints prune` (the explicit,
  human-invoked path) now previews the orphan project list and asks
  for confirmation before deleting, unless -f/--force is passed.
- Docs updated (EN + zh-Hans) to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 16:01:06 -07:00
teknium1
3960315af7 test: order compression-tip fixtures around the closed-parent write guard
Two compression-tip hydration tests simulated legacy state by emptying
the parent AFTER end_session(compression) — exactly the durable write
the new closed-parent guard refuses. Reordered: empty first, close
second. The tests' actual contract (old id hydrates from the live tip)
is unchanged and still pinned.
2026-07-24 16:00:34 -07:00
teknium1
c54fe5b33f fix: pre-lease drift guard must not fire on in-place compaction or mutated snapshots
The salvaged drift check compared durable rows to the in-memory snapshot
by content and ran in both modes. Two problems:
1. In-place compaction (the default) archives non-destructively — drift
   cannot lose data there, and the strict-prefix content comparison
   failed against seeded histories, aborting every in-place compaction
   (5 test failures in test_in_place_compaction.py).
2. Content equality wedges on sessions with legal in-memory mutation of
   past turns (multimodal compression, retry replacement) — the same
   permanent-abort shape as #14694.

Now rotation-only and length-based: abort only when the durable parent
has MORE rows than the snapshot (a writer committed in the lease window).
Dead helper _durable_history_matches_snapshot removed.
2026-07-24 16:00:34 -07:00
teknium1
2b3d442fdb chore: map contributor ruizanthony 2026-07-24 16:00:34 -07:00
Anthony Ruiz
0ee8d41878 fix(compression): recover rotated session lineage 2026-07-24 16:00:34 -07:00
teknium1
62ee34570e test: accept kwargs in managed_uv fixture fakes
The runtime-repair change passes repair_observer= to update_managed_uv/
ensure_uv; the autouse fixture fakes had zero-arg signatures and raised
TypeError through the mock. Sibling test file to the PR's own suite.
2026-07-24 16:00:03 -07:00