On Windows a reserved-device-name file (nul/con/prn/aux/com1-9/lpt1-9) can land
in the working tree (e.g. a shell command redirecting to `> nul` under Git Bash,
which treats it as a literal filename). git can't delete it via the normal Win32
path, so `git clean -fd` exits non-zero — and force_apply_update treated that as
fatal, aborting the whole force update with 'Failed to remove untracked files
before force reset' and leaving the user stuck.
The clean is now best-effort: a failure is logged (stderr captured for
diagnostics) but non-fatal, because the subsequent `git reset --hard` overwrites
any tracked-file collisions regardless, and residual untracked files git can't
delete are harmless. Updates the prior test that asserted the (buggy) fatal
behavior to the corrected non-fatal contract, plus a new regression test.
Reported + root-caused by @rodboev.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Codex gate: the apply path now surfaces sanitized non-network fetch stderr, but
_QUERY_SECRET_RE only redacted access_token/token/password/auth/key — leaving
client_secret, private_token, oauth_token, api_key in a remote URL exposed in
the user-facing error. Broadened the pattern to cover those + app_secret/secret
+ api[_-]?key. Regression test added + proven non-vacuous.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Both Windows restart paths (api/updates._schedule_restart Update-button + bootstrap.py
supervisor auto-restart) now prefer pythonw.exe over python.exe and add CREATE_NO_WINDOW
to the Popen creationflags, so a restart no longer flashes an empty console window. Both
edits are inside sys.platform=='win32' guards (zero Linux/macOS change); bootstrap resolves
the win32-only flags via getattr(subprocess, attr, 0). Added regression test (RED on master,
GREEN here). Code byte-faithful from PR head 6d46e630e3.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: perejaslav <perejaslav@users.noreply.github.com>
Docker images and pip installs have no .git directory, so _check_repo
returned None and the frontend interpreted both update-target parts being
falsy as "Up to date" — a stale Docker image would misleadingly look
current. _check_repo now returns a minimal sentinel dict ({name, behind:
None, no_git: True}) instead of None so the Settings panel can distinguish
"can't check" from "up to date", and _formatUpdateTargetStatus /
checkUpdatesNow surface a localized "Can't check for updates" state
(settings_update_no_git, added across all 13 locale blocks). In mixed
deployments where one component has a git checkout and the other does not,
the valid git-side update info is still shown. The sentinel's behind: None
flows safely through the existing notification/summary builders, which all
gate on int(info.get('behind') or 0) > 0.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
The update-check payload now carries a `dirty: bool` reflecting the
working-tree state vs HEAD, computed via the same `git diff-index
--quiet HEAD --` primitive already used by `_dirty_suffix()`. A dirty
install at-or-past the latest release tag used to silently report
"Up to date" with no remediation affordance; the Settings panel
can now read this flag to offer `apply_force_update` (which already
does `git checkout .` + `git reset --hard` to reset to a clean
latest).
The probe is conservatively clean on real errors (timeout, missing
git, fatal) so a transient probe failure never produces a false-
positive "local changes" alert.
Closes#4085
Fix-ourselves pickup of #3774 (@bambalados). _purge_agent_pycache() before os.execv() in _schedule_restart() so the re-exec'd process recompiles freshly-pulled source — fixes AttributeError on first chat after self-update. Full suite 8180 passed, Codex SAFE, Opus SHIP-IT. Co-authored-by: bambalados <bambalados@users.noreply.github.com>
* fix(security): ignore spoofable forwarded IPs in onboarding gate + make update-check CSRF-safe (#3758, partial)
Ships the two unambiguous slices of #3758's security review. The two slices with
breakage risk for existing installs — the Docker-default public-bind-requires-auth
gate and removing /tmp from the /api/media allowed roots — are held for separate
review/decision.
Onboarding forwarded-IP spoof hardening (+ release-gate CORE fix):
- The unauthenticated first-run onboarding local-network gate now IGNORES
X-Forwarded-For / X-Real-IP by default (a direct client can spoof them to a
private/loopback address to bypass the gate), trusting them only when
HERMES_WEBUI_TRUST_FORWARDED_FOR=1 is set behind a trusted proxy (rightmost
proxy-appended hop).
- Release-gate (Codex) CORE catch + refinement: when forwarded headers are
present but untrusted, the header is ignored and locality is judged by the raw
socket — but a PRIVATE/LAN raw socket (a separate proxy box that could forward
an arbitrary public client) is no longer treated as local; only a LOOPBACK raw
socket is (genuine same-host; a remote attacker can't forge a 127.0.0.1 TCP
source). This closes the new fail-open the initial refactor introduced (public
client behind a LAN proxy read as local) while preserving genuine same-host
onboarding. LAN-proxy operators must set HERMES_WEBUI_TRUST_FORWARDED_FOR=1.
Regression tests lock the full matrix (spoof-block, LAN-proxy-deny,
loopback-allow, trusted-proxy-rightmost-hop, direct-public-deny).
- Three duplicated inline gate blocks unified into _onboarding_gate_allows /
_onboarding_request_is_local; ONBOARDING_OPEN normalized to canonical truthy
values via _truthy_env.
Update-check CSRF hardening:
- GET /api/updates/check is cache-only (cached_update_status(): no network/git
mutation); forced refresh moves to POST /api/updates/check {force:true}; both
frontend call sites updated and the test_api_timeout contract assertion updated.
- cached_update_status() preserves cached agent info when include_agent re-enabled.
Docker log masking: ENV_OBFUSCATE_PART also masks PASSWORD/SECRET/CREDENTIAL/COOKIE/SESSION.
Held for separate review (NOT in this PR): public-bind-requires-auth startup gate
(server.py + Dockerfile default) and the /api/media /tmp-root removal.
Co-authored-by: fantasticsquirrel <[email protected]>
* docs(changelog): stamp v0.51.307 — Release JW (stage-a3 #3758 partial)
---------
Co-authored-by: nesquena-hermes <[email protected]>
You were right — the original analysis was inverted. The unprefixed
form (sys.argv only) is the correct fix for frozen/packaged builds
(PyInstaller, zipapp, etc.) where sys.argv[0] == sys.executable ==
<binary>; master's [sys.executable] + sys.argv form re-inserts the
binary as argv[1] in that case, which the interpreter then tries to
parse as the script to run — leading to a recursive-reexec that
never reaches bind().
But for a source checkout launched as `python server.py` via
bootstrap.py / ctl.sh / start.sh, sys.argv[0] is the SCRIPT path and
sys.executable is the interpreter. CPython treats argv[1] as the
script to run, so we must pass [sys.executable] + sys.argv — the
canonical CPython re-exec idiom.
The two cases are mutually exclusive on argv shape, so a flat swap
can't be right for both. Distinguish with sys.frozen (set by
PyInstaller / zipapp / similar) and pick the right form per case.
Also reworded the last-resort except comment — the prior
"(e.g. frozen binary)" parenthetical was misleading; this branch is
the unconditional fallback for any execv failure, not specifically
the frozen case.
When agent checkouts track main past an older tag but the newest published
tag is on a divergent side branch, stop advertising tag-based updates and
route check/apply through the upstream branch instead.
_run_git used subprocess.run(text=True) without an explicit encoding, so on
Chinese Windows (and other non-UTF-8 codepages) git stdout was decoded with the
locale codepage. _dirty_suffix() runs `git diff --binary HEAD`, whose binary
bytes are not valid GBK, raising UnicodeDecodeError in the subprocess reader
thread. That left r.stdout = None, so `r.stdout.strip()` raised AttributeError
during module import of api.updates, crashing server.py before it could bind
its port.
Force UTF-8 decoding with errors=replace and guard against None defensively.
MUST-FIX:
- tests/test_2735_open_in_vscode.py: bump expected open_in_vscode locale
counter from 10 to 11 (Turkish locale added in #2772). The bump fell
out of an in-rebase test edit but never got committed; tagging without
this would have shipped a failing test in the release commit.
SHOULD-FIX inline:
- api/updates.py: case-D drift in _select_apply_compare_ref. The original
#2855 fix used latest_tag in the past-tag predicate; the check side
uses current_tag (HEAD's nearest reachable tag) plus a 'behind == 0'
gate. They drift when HEAD is on an OLDER release tag with commits on
top AND a NEWER tag exists ('case D'): check correctly suggests
advancing to the newer tag, but apply fell through to origin/<branch>.
Mirror the check-side predicate exactly. Adds regression test
test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_exists.
- static/messages.js: post-await race guard in _restoreSettledSession.
stream_end without preceding 'done' enters the settlement path, awaits
/api/session, then sets _streamFinalized=true. If a late 'done' event
arrives during that await, it sees _streamFinalized still false and
double-runs the finalize. The guard returns early when done won the
race, avoiding double renderMessages() + double notification.
- server.py: CORS preflight Access-Control-Allow-Methods now includes PUT.
#2776 wired PUT into the router for /api/mcp/servers/{name} but didn't
update the OPTIONS response. Same-origin only in practice, but cosmetic
completeness for CORS-aware deployments.
Opus advisor verdict: all 5 risk areas reviewed, 1 MUST-FIX + 3 SHOULD-FIX
all addressed inline. Net: +69/-9, no new architecture, no behavior risk.
Fixes#2846. After PR #2758 (the #2653 fix) the update check correctly
falls through to the branch comparison when HEAD has moved past the
latest `v*` tag — so the banner reports the real commit count against
`origin/<branch>`. But `_select_apply_compare_ref` was never updated to
mirror that decision: as long as any `v*` tag exists, it returns
`tags[0]`, even when HEAD is far past it.
Result for everyone running hermes-agent past `v2026.5.16` (i.e. anyone
on agent master between tagged releases):
1. Banner: `Agent (origin/main): 254 updates available` ← correct
2. User clicks Update Now
3. `_select_apply_compare_ref` picks `v2026.5.16` because tags exist
4. `git pull --ff-only origin v2026.5.16` — no-op (HEAD is already past it)
5. `_schedule_restart()` fires anyway, server bounces
6. Next check still reports 254 behind — banner reappears unchanged
`apply_force_update` had the same bug, except worse: `git reset --hard
v2026.5.16` would have actively rewound the user's checkout 254 commits.
The root cause is the same bug class as #2653 — two parallel paths
(`_check_repo_release` and `_select_apply_compare_ref`) that should make
the same decision but didn't. Pre-fix, the "is HEAD past the latest
tag?" predicate lived inline inside `_check_repo_release` only.
Fix
---
Extract `_head_is_past_latest_tag(path, current_tag)` and have both
paths consult it. When HEAD is past the latest tag:
- check path: release check returns None → branch check runs (#2653,
unchanged behaviour, just refactored)
- apply path: falls through to upstream / `origin/<branch>`, never the
stale tag (#2846, new behaviour)
Tests
-----
- `test_select_apply_compare_ref_uses_tag_when_head_is_on_tag` —
unchanged behaviour pinned: HEAD exactly on tag → advance to tag.
- `test_select_apply_compare_ref_falls_through_when_head_is_past_tag` —
the #2846 repro: HEAD = v2026.5.16 + 608 commits → advance to
`origin/main`, not the tag.
- `test_select_apply_compare_ref_no_tags_uses_upstream` — unchanged.
- `test_select_apply_compare_ref_no_tags_no_upstream_uses_default_branch`
— unchanged.
- `test_check_and_apply_paths_agree_when_head_is_past_tag` — symmetry
test, ensures the two paths can't drift apart again.
All 21 tests in `tests/test_updates.py` pass locally (16 existing + 5
new).
Refs #2846, #2653.
When current_tag == latest_tag, _check_repo_release returned behind=0
and reported 'Up to date' even if master had moved hundreds of commits
past the tag. This was visible as Agent: v2026.5.16-593-gedb2d9105
alongside a green 'Up to date' pill in Settings.
Run 'git describe --tags --always' after computing behind==0. If the
output includes a -N-gSHA suffix the tag is not at HEAD; return None so
_check_repo_branch runs and counts the real commit gap via rev-list.
When HEAD is exactly on the latest tag the new branch is never taken and
behaviour is unchanged.
Fixes#2653.
Without --force, git fetch origin --tags refuses to overwrite divergent
local tags and returns 'would clobber existing tag', jamming the entire
WebUI update path indefinitely. The WebUI is a release-tracking consumer
that never pushes tags, so it should always defer to whatever the remote
says a release tag points to. Add --force to all three fetch-tag call
sites:
- _check_repo (the 'Check now' button + periodic check)
- apply_force_update (force-reset to remote HEAD)
- apply_update (stash + pull --ff-only)
Tests:
- Updated 3 existing tests in test_updates.py whose fake_git mocks
asserted the exact ['fetch', 'origin', '--tags'] args list.
- Updated 1 existing test in test_update_banner_fixes.py that asserted
the same shape for apply_update.
- Added 4 new regression tests:
- test_check_repo_fetches_tags_with_force
- test_apply_force_update_fetches_tags_with_force
- test_apply_update_fetches_tags_with_force
- test_check_repo_recovers_from_remote_retag (end-to-end,
proves the bare --tags fetch shape is no longer used)
Closes#2756.
Keep distinct generated summary categories, route update-summary generation through the configured auxiliary model first, disclose capped large-range summary input, and constrain long summary panels.
Refs #2215 Fix A: replace plain dict _summary_cache with OrderedDict-based LRU capped at 16 entries to prevent unbounded memory growth from long-running update summary generations.
Add regression coverage for the bounded LRU behavior: cache hits refresh recency, a new entry at capacity evicts the least-recently used key, and cache size never exceeds the cap.
Closes#1579.
api/updates.py was building the GitHub compare URL from local HEAD short SHA:
repoUrl + '/compare/' + curSha + '...' + newSha
where curSha = `git rev-parse --short HEAD`
Whenever local HEAD diverges from upstream — unpushed work, dirty stage
branches, forks, in-flight rebases, release-time merge commits whose SHA
only lives in the maintainer's local history — the compare URL points at
a SHA github.com has never seen and returns the standard 404 page.
Reporter (@ai-ag2026) observed:
https://github.com/nesquena/hermes-webui/compare/c660c7f...86cb22e
→ 404 because c660c7f was an unpushed local commit.
The right base is `git merge-base HEAD <compare_ref>` — the most recent
commit local and upstream share. Since `git fetch` succeeded just before,
the merge-base is guaranteed to exist on the upstream GitHub repo.
Behavior matrix:
Pure-behind clone (no local commits): merge-base == HEAD; URL unchanged.
Behind + local-only commits (#1579): merge-base != HEAD; URL points at
public ancestor instead of local HEAD.
merge-base failure (shallow clone): current_sha=None; JS link guard
suppresses link rather than emitting
a known-broken URL.
Also hardens static/ui.js: reset the link's href and display:none on every
banner render, so a stale link from a prior render can't survive a re-render
where the new payload has current_sha=null.
Tests:
- test_current_sha_is_merge_base_not_local_HEAD — reporter's scenario
- test_current_sha_equals_HEAD_when_no_local_commits — backward compat
- test_current_sha_falls_back_to_None_when_merge_base_fails — defensive
- test_whats_new_link_resets_display_and_href_on_every_render
- test_whats_new_link_suppressed_when_curSha_falsy
- test_reporter_url_shape_no_longer_produces_invalid_compare_url
4094 → 4100 passing. 0 regressions.