Gate finding on #7336: read_body() rejected whitespace-only bodies that
worked on master (a body-optional DELETE/PATCH/PUT can send b' \n'), returning
400 Invalid JSON body. Normalize a fully-whitespace body to {} before json.loads,
matching the pre-#7336 lenient behavior for that shape. Adds a parametrized
regression test.
Co-authored-by: zicochaos <zicochaos@users.noreply.github.com>
Observed: on a multi-profile install where one profile's state.db predates
the agent's `source` column, errors.log gained one identical line every
~15 s (276 lines in ~70 min):
api.models WARNING agent session listing skipped: state.db at
/data/hermes/profiles/planner/state.db has no 'source' column (older
hermes-agent?). Agent sessions unavailable. Upgrade hermes-agent to
fix this.
Mechanism: api/agent_sessions.py read_importable_agent_session_rows()
runs `PRAGMA table_info(sessions)` and warns unconditionally when the
column is absent (api/agent_sessions.py:564 before this change).
api/models.py get_cli_sessions(all_profiles=True) calls it for every
profile on every sidebar poll, behind only the 5 s
_CLI_SESSIONS_CACHE_TTL_SECONDS cache, so the same DB re-emits the same
warning on every cache miss. The condition is a property of the DB
file, not of the poll.
Fix: a module-level `_SOURCE_COLUMN_WARNED_DB_PATHS: set[str]` keyed by
the resolved db_path. The warning fires the first time a given path is
seen in the process and is suppressed after that; a different path
warns on its own. Message text is unchanged. Process-lifetime only: a
restart warns again, which is intended (the line remains the operator's
cue that the agent still needs upgrading). Plain set mutation under the
GIL is sufficient; a duplicate line from two racing first calls is
harmless.
Tests (tests/test_issue634.py, real pre-`source` sqlite DBs under
tmp_path, asserting on captured log records):
- same db_path called twice -> exactly one WARNING (failed before:
`assert 2 == 1`)
- two db_paths, then the first again -> exactly two WARNINGs, one per
path (failed before: `assert 3 == 2`)
The seven existing #634 source-shape tests still pass; neighbours
test_issue3762/3930/5455/1494 pass.
Deliberately not changed: the exception-path warning in
get_cli_sessions() (api/models.py), the read-only-open fallback warning,
and the 5 s cache TTL. The operator-side root cause (the agent
reconciler creating a pre-`source` state.db) is a separate upstream fix;
this only stops the repetition.
Title generation via the aux client ran without the Agent's ambient
conversation context, so relay targets that key on it (OpenCode Go)
rejected the completion call with HTTP 400.
Publish the webui session id as the Agent conversation context around
the aux completion (restoring it in a finally block) and pass it from
all three title entry paths: background update, background refresh and
the on-demand regenerate helper.
Refs #7470
Two Codex adversarial-security-gate findings on the batch:
1. #7331 CORE (backward-compat break): making '|' an unconditional trusted-groups
separator would re-split an existing deployment whose group NAME legitimately
contains a literal '|' (e.g. 'unprivileged|admins' read as one group), silently
changing its profile binding. Pipe splitting is now OPT-IN via
HERMES_WEBUI_TRUSTED_GROUPS_PIPE_SEPARATOR; comma/newline stay the default.
Tests: literal-pipe preserved by default, pipe splits only with the flag set.
2. #6772 follow-up (my own hardening introduced it): rebuilding the origin from
parsed parts dropped the brackets around an IPv6 host, so 'http://[::1]:8787'
became 'http://::1:8787' and never matched the browser's bracketed
clientDataJSON.origin -> passkey login over IPv6 would fail. Re-bracket IPv6
literals. Added rp_context regression tests (Origin-first, port preserved,
IPv6 bracketed, malformed->Host fallback, userinfo stripped).
Follow-up on #6772. Preferring Origin over Host for the WebAuthn RPID is correct
(the RPID must match the page origin, not the backend's), and it is not a trust
decision: the authenticator only releases a credential whose RPID is a registrable
suffix of the real page origin, clientDataJSON.origin must equal the stored origin,
rpIdHash must match the stored RPID, and the assertion signature is verified against
the stored credential public key.
Still, only accept a syntactically valid http(s) Origin carrying a hostname, and
rebuild the stored origin from the parsed scheme/host/port instead of echoing the raw
header — so a malformed, non-http (javascript:/file:), or userinfo-bearing Origin
falls through to the Host-derived path rather than being stored verbatim.
The Hermes Agent's state-module refactor (agent commit 53db597201, released
v2026.9.7) renamed SessionDB.set_auto_title_if_empty -> set_auto_title(
session_id, title, *, source) and (agent commit 14791b4d4e) dropped the
tools.approval._ApprovalEntry back-compat facade (class moved, unchanged, to
tools.approval_gateway_wait).
Production impact (real, silent): api.state_sync.sync_session_title called the
removed set_auto_title_if_empty, whose AttributeError was swallowed by the
surrounding `except Exception: logger.debug(...)`, so WebUI auto-generated
session titles stopped persisting to state.db on agent >= v2026.9.7 — hermes
sessions list went blank again (the exact #6892 regression). Now calls
set_auto_title with LLM provenance, which preserves the identical semantics
(only fills NULL/lower-authority title, returns False untouched on a manual
rename, ValueError -> get_next_title_in_lineage #N de-dup).
Test fixes (agent-coupled; skip on GitHub CI which installs no agent, so they
only surface on an agent-equipped box like our gate):
1. tools.approval._ApprovalEntry -> idempotent autouse conftest compat shim
backfilling the class from its post-split location only when the installed
agent lacks the dropped facade. No production code imports it.
2. test_goal_command_webui::test_profile_goal_falls_back_when_session_db_path_is_frozen
was missing the monkeypatch.syspath_prepend(...) that every sibling test in
the file has, so its bare `import hermes_state` failed under full-suite
ordering after a predecessor stripped the agent dir from sys.path. Added it.
Verified: authoritative 5-shard CI-equivalent split all green (0 failed);
each failure proven pre-existing on clean master (not this diff).
The not-found reply capped available_skills at 20 with no total and no
truncation marker, so a caller with more skills than the cap was handed a
partial list that reads as the complete set. A skill sorting past the cut
looked uninstalled.
Keep the bound; stop it lying. The payload now carries total_skills and
available_skills_truncated, and the hint names how many of how many were
shown, matching the (list, truncated) shape already used for capped commit
subject lists in api/updates.py.
hermes-agent v0.21 removed agent.anthropic_adapter.normalize_anthropic_response and the AIAgent._normalize_codex_response method; the sanctioned out-of-band contract is now agent._get_transport(<mode>).normalize_response(...) returning NormalizedResponse (.content). Port the anthropic_messages and codex_responses branches of generate_title_raw_via_agent (api/streaming.py) and _agent_text_completion (api/routes.py); on agent v0.21 those branches currently die on import/attribute errors and silently fall back, degrading title generation and handoff summaries. build_anthropic_kwargs, _anthropic_messages_create, and _run_codex_stream are unchanged in v0.21 and stay as-is.
Named custom providers are only routable when the slug resolves to a real,
unique custom_providers[] entry. A stale session provider such as
'custom:missing' must not be minted into an @custom:missing:... route -
resolve_model_provider() would take the named-provider lane and find no
matching endpoint. _unique_custom_provider_entry() returns None for unknown
slugs and raises AmbiguousCustomProviderError for collisions, matching the
point-of-return guard used by resolve_model_provider.
Addresses maintainer review on #7356. Adds two regression tests: unknown
named custom slug stays bare, ambiguous normalized slug fails closed.
model_with_provider_context() passed any slash-bearing model ID through
bare when the session provider was not OpenRouter/ACP/plugin and not
listed under config.yaml -> providers:. That dropped the hint for portal
providers (nous, nvidia, opencode-zen, opencode-go) and named custom
providers (custom:<slug>), so resolve_model_provider() fell back to the
profile default provider + base_url and sent a vendor-scoped id to the
wrong endpoint (HTTP 404, e.g. upstage/solar-pro4:free to api.x.ai).
Emit an explicit @provider:model hint for any known routable provider
that differs from the configured default; keep the bare passthrough for
unknown/ambiguous provider slugs so custom/proxy base_url routing stays
in charge. Closes#7333.
`_strip_compact_echo_suffix` probed every candidate cut index and re-folded
the whole remaining tail for each probe, so the cost grew with
`window x suffix` rather than with the data actually inspected. It runs up to
four times per interim assistant message, on the reasoning buffer, the shared
stream mirror and up to two per-message segments.
The practical effect is worst on the longest message of a turn: 297ms for a
200-character message but 5.6s for a 6000-character one, measured on the live
process. That time is spent holding the GIL, so a single long final message
also stalls every other stream served by the same worker. Profiling a live
turn attributed ~5% of total CPU to this one helper.
Fold the window once, then walk backwards across exactly as many non-space
characters as the folded suffix holds. `str.isspace` and the `\s` pattern used
by `_compact_for_echo_compare` agree on all 1114112 Unicode code points, so
the folded view and the walk stay consistent.
Behaviour is unchanged, including the leftmost-cut tie-break on repeated text
and the bounded search window. The new test pins equivalence against a naive
reference oracle over ~2020 cases (exact, partial and absent echoes, mixed
whitespace, unicode, empty and None inputs, repetitions, out-of-window
echoes), asserts the fold no longer runs once per cut index, and caps the
runtime of a long-conclusion call.
Measured: 200 chars 297ms -> 0.149ms, 6000 chars 5645ms -> 0.720ms.
read_body() silently turned malformed JSON into {} (clients then got a
misleading 'Missing field' 400) and passed non-dict JSON through to handlers
where body.get() raised AttributeError and surfaced as a generic 500.
Raise ValueError instead — handle_post already maps it to a clean 400 — and
give the PATCH/DELETE/PUT dispatchers the same mapping they lacked. Empty
bodies keep the documented {} default.
Authentik's proxy provider on this deployment joins multiple group
names with "|" instead of "," (e.g. "admins|developpeur"). The
comma-only split in _trusted_groups_header_value() treated that whole
string as a single unmatched group name, silently dropping the
session to the unbound "default" profile even for identities that
are legitimate members of a mapped group. Single-group identities were
never affected since there was no separator to parse.
Accept comma, pipe, and newline as equivalent separators so the parser
works regardless of which the outpost happens to send.
* perf(session-load): memoize the compression-lineage display stitch
_webui_sidecar_lineage_messages_for_display re-merged multi-thousand-row
parent+child transcripts on EVERY GET /api/session (measured 5.1s per
request on a real 5.2k-message lineage, py-spy: merge_session_messages_
append_only + _matching_visible_duplicate dominate). The merge result is
now cached per session id, validity keyed by the exact stat signature of
the child sidecar AND every snapshot parent involved, bounded LRU(16).
Any write to any involved sidecar invalidates naturally; cache hits and
the populating call hand out shallow-copied rows so caller-side display
metadata cannot corrupt cached state.
Measured: 5.17s cold -> 0.002s warm on the real session; invalidation
after touch re-merges. Tests: tests/test_lineage_display_cache.py
(correctness, copy isolation, child/parent invalidation, no pollution
for lineage-less sessions).
* perf(api): memoize inactive-session display merges
* perf(api): skip state load on cached transcript reopen
* test(api): remove unused lineage-cache import
* fix(api): reject streaming key without state database
* test(api): isolate display cache fixture
* fix(api): bind cache publication to read signature
* fix(api): harden display cache invalidation
* fix(api): bind cache hits to lineage provenance
* fix(api): close remaining display cache scope races
* fix(api): scope display cache revision to target
* fix(api): evict unverifiable lineage provenance
* fix(api): keep incomplete lineage provenance uncached
---------
Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
_split_webui_provider_model_value() and _strip_webui_provider_prefix()
split provider-qualified model values at the last colon. That amputates
any model name carrying its own tag: @ollama:qwen3.8:27b-mtp-q8_0
resolved to model 27b-mtp-q8_0 with provider ollama:qwen3.8, and the
truncated name was sent upstream, which 404'd. The splitter also cannot
distinguish a tagged model from a multi-segment custom provider ID, so
@custom:mykey:model-a:free was affected the same way.
Both sites now delegate to config._parse_provider_qualified_model_id(),
the grammar routes.py and the gateway adopted in #6723. The import is
function-local, matching the existing api.config imports in this module.
_split_webui_provider_model_value() persists its result into profile
config, so the mangled name outlived the session that produced it.
Fixes#7182.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix(workspace): new chat inherits the current conversation's workspace
`newSession()` resolved the workspace of a new chat as:
switchWs || S._profileDefaultWorkspace || S.session.workspace
`S._profileDefaultWorkspace` is global to the profile, hydrated from
`/api/profile/active`. Preferring it over the current conversation meant a new
chat opened from a MES conversation landed on the profile-global value instead
of MES. With several conversations running in parallel on different workspaces,
the context the user was actually in was systematically lost.
The precedence is worse than it looks, because that "profile default" is not a
stable configured value: `get_profile_default_workspace()` reads
`last_workspace.txt` BEFORE the configured key (api/workspace.py ~419-427), and
for the `default` profile that file is the GLOBAL one. `set_last_workspace()`
rewrites it on every `/api/chat/start` and `/api/session/update` of ANY
conversation. So "profile default wins over the current session" degrades in
practice to "whatever workspace another conversation last used wins over the
conversation I am in" -- observed live: the pointer flipped mid-operation
because a concurrent turn wrote to it.
New precedence:
switchWs || S.session.workspace || S._profileDefaultWorkspace
Preserved invariants:
- the one-shot profile-switch flag still wins first and is still consumed (#823);
- a blank page (no loaded session) still falls back to the profile default,
which remains required for the empty-composer workspace chip (#804/#5169);
- `_profileDefaultWorkspace` is never nulled by `newSession()` (#823).
This deliberately diverges from upstream #4755, which made the profile default
win. That decision assumed a stable configured default; on a shared/global
`last_workspace.txt` it inverts its own intent. The upstream test is rewritten
with the rationale inline so a future rebase does not silently restore the bug,
plus a new test that evaluates the real expression under node and proves the
blank-page fallback still holds.
Pre-existing failures in tests/test_workspace_stale_recovery.py (3) were
verified identical with and without this change.
* fix(workspace): recover stale inherited workspace
---------
Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
The periodic checkpoint thread fires whenever a tool call completes and
unconditionally rewrites the whole session sidecar. A completed tool call
is not proof that anything the checkpoint persists actually changed:
`run_conversation()` mutates an internal copy of the transcript, and the
turn bookkeeping fields (`active_stream_id`, `pending_user_message`,
`pending_started_at`) are written once before the run starts. So the
recurring rewrite normally re-persists byte-identical state.
Each rewrite serializes the entire session while holding the GIL, so its
cost scales with transcript size. Because the HTTP server and the agent
workers share one interpreter, that cost is added latency for every
concurrent request — not just for the session being written.
Measured on this deployment (1,622 sidecars, 6.65 GB, median 2.31 MB,
28.8% above 5 MB, max 35.9 MB):
sidecar write per 10-min turn (40 wakeups)
1.8 MB ~100 ms 4.1 s -> 0.2 s
24.7 MB ~1.4 s 41.6 s -> 2.1 s
A checkpoint now writes only when the state it persists actually moved,
compared through a cheap fingerprint that allocates nothing. The
fingerprint covers every field the checkpoint can legitimately advance
mid-run: session identity (compression rotates the SID), turn
bookkeeping, and the length of the persisted message projections.
Fail-closed on both sides: an unreadable fingerprint returns None and
forces the write, and a 5-minute idle refresh keeps `updated_at` from
going stale on a long quiet turn (the sidebar sorts on it). Crash
recovery is unchanged — any real mutation is still persisted within one
15 s interval.
Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
The WebUI clarify bridge resolved the prompt timeout from a local, divergent
copy (legacy clarify.timeout, 120s fallback) and treated 0 as invalid, so
agent.clarify_timeout: 0 (unlimited) could not be honored and there was no
way to make a clarification card wait for the user.
Resolve the timeout with the agent core's canonical resolve_clarify_timeout()
(clarify.timeout -> agent.clarify_timeout -> 3600, <= 0 = unlimited) against
the session's own profile config (the streaming worker thread does not inherit
the request's per-profile context), wait with a null deadline when unlimited,
and stop the frontend rendering an expired countdown for non-positive timeouts.
- api/streaming.py: per-session profile config resolution, canonical resolver
with a compatibility fallback, and an extracted _await_clarify_response()
helper that polls in 1s slices and honours cancellation.
- api/clarify.py: preserve timeout_seconds <= 0 and emit no expires_at.
- static/messages.js: _clarifyExpiryMs() returns 0 for non-positive timeouts
so the card stays up without a countdown.
- tests/test_sprint42.py: resolution matrix, per-session profile divergence,
unlimited resolve-later / cancel-exit, and no-expiry payload.
Closes#7159
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix: route overlapping custom_providers models by active endpoint
When two custom_providers[] entries list the same bare model id (e.g.
dogapi and packyapi both advertising 'claude-sonnet-5'),
resolve_model_provider scanned them in config write order and returned
the first match, silently ignoring the active provider's base_url. A
user whose model.base_url pointed at packyapi got routed to dogapi
because dogapi appeared earlier in config.
Now, when the active provider resolves to a named custom provider
(custom:<slug>, including via model.base_url -> named-slug matching),
that provider claims models it owns before the ordered scan runs. Falls
through to the legacy first-match scan when the active provider is bare
'custom' with no base_url to disambiguate, so existing single-endpoint
setups are unaffected.
Adds 3 regression tests covering: shared-model active-endpoint wins,
provider-unique model still routes by ownership, and bare-custom
no-base_url preserves legacy order.
* fix: extend active-endpoint routing to aux, handoff, and slug-collision paths
Review follow-up: the overlapping-id fix must reach three sibling
resolution paths that still routed on the wrong endpoint.
1. Auxiliary slot base_url (config.py set_auxiliary_model): a bare
resolve_model_provider(model) ignored the SELECTED aux provider, so a
custom:A slot was persisted with the active custom:B endpoint when both
listed the model. Look up the selected provider's own custom entry via
resolve_custom_provider_connection. (NOT model_with_provider_context
here: the @custom:<slug>:model form resolves base_url to None, which
would drop the base_url entirely.)
2. Handoff summary (routes.py): read s_obj.model_provider and pass
model_with_provider_context(model, model_provider) so a session pinned
to custom:A is not rerouted to the active custom:B. base_url is
backfilled from the provider's own custom entry downstream.
3. Normalized-name slug collision (config.py resolve_model_provider): two
distinct provider names can normalize to the same slug (e.g. "Foo Bar"
and "foo-bar"). The active-slug guard now matches the EXACT entry by
base_url instead of re-deriving it from the non-unique slug, and fails
closed (falls through to the ordered scan) on genuine ambiguity.
Adds 4 regression tests: normalized-slug base_url disambiguation,
slug-collision fail-closed, aux-slot selected-provider base_url, and
session-provider-context handoff routing.
* fix: fail closed on custom-provider slug collisions and break aux deadlock
resolve_model_provider() returns a provider slug (custom:<slug>) and the
credential lookup (resolve_custom_provider_connection) later resolves the
API key from the FIRST same-slug entry regardless of base_url. So when two
distinct provider names normalize to the same slug (e.g. "Foo Bar" and
"foo-bar") and both list the requested model, returning the slug silently
pairs one entry's endpoint with another entry's credential. Now raise a
clear AmbiguousCustomProviderError instead of guessing, on both the
active-slug guard and the ordered-scan fall-through paths.
Also: an explicit, unambiguous named custom provider now stays authoritative
even when model.base_url is stale/absent and points at neither entry — a
stale URL is no longer evidence to demote it to config order.
And fix a self-deadlock in set_auxiliary_model(): it held the non-reentrant
_cfg_lock while calling resolve_custom_provider_connection() -> get_config()
-> reload_config_if_stale(), which re-acquires the same lock. The selected
custom provider's base_url is now resolved inline from the config_data
already loaded under the lock.
Tests: collision cases now assert the ambiguity error; added a stale-URL
explicit-provider regression and a watchdog-guarded aux no-deadlock
regression (both fail before the fix).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: fail closed on custom-provider slug collisions across all four paths
The previous cut only guarded the symmetric bare-model case, and it built
collision membership from entries that OWN the requested model. But credential
resolution scans ALL same-slug entries and first-matches regardless of
ownership, so several slug-only boundaries could still pair one entry's
endpoint with another entry's credential — including the asymmetric case where
only one colliding entry lists the model.
Introduce one shared, pure/lock-safe helper (_custom_provider_slug_key +
_unique_custom_provider_entry) that groups ALL named custom_providers entries by
a single canonical slug normalization and raises AmbiguousCustomProviderError
whenever a consumed slug maps to >=2 entries. Apply it at every slug-only
boundary so endpoint and credential can never come from different entries:
- bare resolution (resolve_model_provider): all-entry membership for both the
active-slug guard and the ordered ownership scan (was owner-only).
- provider-qualified @custom:<slug>:model return (the session/send/handoff
model_with_provider_context shape), which previously bypassed the check.
- credential lookup (resolve_custom_provider_connection), replacing its private
first-match-by-slug scan and its duplicate _slugify.
- auxiliary persistence (set_auxiliary_model), replacing its own inline
first-match-by-slug; the ambiguity now propagates (fails the save) instead of
being swallowed, while other best-effort errors stay non-fatal. Still resolves
from the in-scope config_data so it remains lock-safe (no deadlock regression).
Tests: add fail-closed regressions for the asymmetric A-first-non-owner/B-owner
bare case (active-endpoint and bare-custom paths), the model_with_provider_context
qualified handoff shape, the credential boundary called directly, and the
auxiliary-save ambiguity (asserts nothing is persisted). All five fail before
this change and pass after.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: one canonical custom-provider slug identity, point-of-return ambiguity, profile-scoped credentials
Deep re-gate follow-up (5 findings):
1. Collision detection now derives from the single slug PRODUCER
(_custom_provider_slug_from_name) instead of a looser private key, so
producer-level collisions on punctuated/parenthesized names (e.g.
'Foo (Bar)' vs 'foo-bar', both -> custom:foo-bar) are detected. Previously
the looser key normalized 'Foo (Bar)' to 'foo-(bar)' and missed the
collision, reintroducing the endpoint-A/credential-B bug for those names.
2. The all-entry ambiguity check now runs ONLY at the point a custom:<slug> is
about to be returned (via a _finalize() guard covering every custom-return in
resolve_model_provider, including the final active-provider fallback), not up
front. An unrelated collision on the active slug no longer blocks lanes that
resolve to a different provider (@openrouter, a non-colliding @custom:other).
3. streaming.py now resolves endpoint AND credential inside ONE profile scope:
the main path extends the scope over runtime-key + custom-provider resolution,
and both self-heal retry paths bind the session profile. This stops a detached
worker from pairing a named profile's endpoint with the default profile's key.
4. Handoff summary: AmbiguousCustomProviderError now returns HTTP 400 with the
actionable rename message instead of degrading to a 200 local fallback the UI
treats as success.
5. UI: aux + default model saves surface the server's message and abort (retain
dirty state) instead of swallowing it into a generic failure / "settings
saved"; the handoff catch shows the 400 rename message verbatim.
AmbiguousCustomProviderError gains a .message attribute for verbatim forwarding.
Tests: parenthesized-name collision fails closed on all direct paths; unrelated
lanes are not blocked by an active collision; custom overrides resolve inside the
passed profile scope; handoff returns 400 (not a persisted fallback); JS
error-surfacing source guards. Verified the finding #1/#2 cases fail before and
pass after (old key misses 'Foo (Bar)'/'foo-bar'; old up-front check over-blocks).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix(transcript): recognize agent row ids in replay merge
* Strip imported _row_id so replay merge cannot trust caller provenance.
Session import already dropped _state_db_row_id aliases. After treating
Agent _row_id as identity, the same public/import scrubber must drop that
field too, or a crafted import can collide with a later state.db turn.
---------
Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix(workspace): preserve remote POSIX paths without macOS synthetic resolution (#7131)
When hermes-webui runs on macOS with a remote terminal profile pointing to
a Linux host (e.g. terminal.cwd='/home/<user>'), path resolution previously
invoked Path.resolve(), expanding '/home' via macOS synthetic firmlinks to
'/System/Volumes/Data/home/<user>' and failing validation.
- Return Path(normalized_raw) directly from _remote_terminal_workspace_candidate
- Check _remote_terminal_workspace_candidate in _clean_workspace_list before local _safe_resolve
- Add unit test covering Linux /home/<user> remote paths without macOS expansion
* ci: trigger required checks (workflow did not run on fork head)
---------
Co-authored-by: n <a@n>
The prior change loosened the shared _is_safe_relative_path() to permit dot-prefixed
segments. That validator also gates static serving, asset URLs and manifest paths, so
loosening it made hidden files reachable: GET /extensions/<id>/.env, /.git/config and
/.git/hooks/pre-commit return 200 — credential exposure plus a git-hook write/execute
vector.
Restore _is_safe_relative_path() to strict (reject every dot-prefixed segment) and add
a separate _is_safe_archive_member() wired only into the install/uninstall loops. It
permits a narrow allowlist of benign hidden LEAF files (.gitkeep, .gitignore,
.gitattributes, .env.example) so extensions can still ship them, while dot-directories
(.git/) and all other dotfiles (.env, .secret) stay rejected.
Adds regression coverage for both validators: traversal, NUL/backslash, dot-directories,
and the leaf allowlist edges.
* fix(sse): scope gateway SSE probe and honor Last-Event-ID on /api/chat/stream
Two narrowly-scoped cross-client SSE fixes from the hermes-android#58
follow-up:
1. The /api/sessions/gateway/stream?probe=1 response only describes the
optional gateway/agent-sessions stream, but its payload gave clients no
way to tell that. A 404 'agent sessions not enabled' result could be
misread as SSE being unsupported server-wide, even though the persistent
per-session stream (/api/session/stream) is always on and independent of
the show_cli_sessions setting. Add additive scope markers to the probe
payload (scope, session_stream_available, session_stream_path) and
clarify the handler docstring.
2. /api/chat/stream emitted id: stream_id:seq on every journaled event
but ignored the Last-Event-ID request header, so any spec-compliant SSE
client that auto-reconnects (browser EventSource, Android/CLI clients)
replayed the journal from seq 0 and double-rendered the transcript.
_chat_stream_resume_after_seq() now resolves the resume cursor with
explicit after_event_id/after_seq query params first and the
Last-Event-ID header as fallback — the same resolution chain
api/kanban_bridge.py already uses. Both the dead-stream journal replay
and the live-subscribe gap check consume it; direct callers of the gap
check that only pass query params keep historical behavior.
Also documents the cross-client SSE endpoint contract in
docs/sse-streams.md.
Related hermes-webui/hermes-android#58
* fix(sse): split cursor presence from validity on /api/chat/stream resume
Address PR #6886 review (gate-fail): the resume logic conflated "no
cursor" with "invalid cursor" into a single None, which broke replay on
three edges. Root cause and fix:
Cursor presence vs validity — _chat_stream_resume_cursor() now returns
(after_seq, resume_requested) instead of a bare seq. A client that
SUPPLIED any cursor (query params, replay=1, or Last-Event-ID) asked to
resume; an invalid / foreign-run / unparseable cursor resolves to
after_seq=None but keeps resume_requested=True, and is normalized to
replay-from-start so no journal events are silently skipped. A genuinely
cursor-less request stays a fresh subscribe.
1. Explicit-query precedence is now decided by parameter PRESENCE, not
successful parsing: a malformed/foreign after_event_id/after_seq never
falls through to let the Last-Event-ID header override it and skip
events.
2. Invalid/foreign cursors no longer bypass replay + gap detection and
silently drain a truncated buffer; an ahead-of-stream cursor is no
longer clamped onto the snapshot's terminal fence (which emptied the
dead-stream replay by filtering the terminal frame).
3. The runner-local observe path now receives the resolved header cursor
(retaining explicit-query precedence) so header-only runner reconnects
resume instead of duplicating events from the start.
Regression tests cover invalid-cursor replay-from-start, ahead-of-stream
terminal survival, header-only runner reconnect, and explicit-query
precedence; the stale clamp test is updated to the corrected contract.
Related hermes-webui/hermes-android#58
* fix(sse): consume resolved resume cursor at every consumer on /api/chat/stream
Address PR #6886 r2 review (gate-fail). The r1 presence/validity split
fixed the entry resolution, but the individual consumers still re-derived
ahead/equality/re-parse decisions, so the invariant leaked at the edges.
This makes every consumer honor the resolved (after_seq, resume_requested)
pair and normalizes ahead-of-stream in one place.
1. Equality off-by-one: a valid cursor EQUAL to the snapshot cutoff now
enters the drain dedup bound (the event at the cursor was already
delivered), instead of being treated as "ahead" and double-sending the
retained buffer. Ahead is now strictly after_seq > snapshot_cutoff_seq.
2. Numeric ahead-of-stream cursors are normalized to replay-from-start:
on the active path, after_seq > snapshot_cutoff_seq resets to 0 before
gap-coverage/replay so a truncated buffer can no longer falsely declare
coverage and lose the pre-tail events; on the dead-stream path the
cursor is normalized against the journal summary's authoritative
last_seq before _replay_run_journal, so an ahead cursor returns the
full replay instead of an empty SSE body.
3. Runner-local now consumes the resolved cursor instead of re-parsing
after_event_id or re-reading Last-Event-ID. The resolver also returns
the opaque raw cursor so the runner uses the already-precedence-checked
value; explicit opaque cursor= keeps precedence, invalid/foreign
cursors pass no cursor (full replay), and the header is honored only
when no explicit cursor was supplied.
Regression tests cover the equality dedup, active+dead ahead-of-stream
normalization (with after_seq-filtering mocks that assert the real replay
body), and the three runner precedence probes (malformed-explicit blocks
header, foreign passes no cursor, opaque cursor= wins).
Related hermes-webui/hermes-android#58
* fix(sse): carry runner-cursor provenance through /api/chat/stream resume
Address PR #6886 r3 review (gate-fail). The (after_seq, resume_requested,
raw_cursor) tuple carried the cursor VALUE but not its PROVENANCE, so the
runner handoff couldn't distinguish a valid opaque runner cursor from a
journal-shaped one from a malformed one — and gated the runner handoff on
journal-specific validity, which is wrong for the runner adapter (its
cursors are opaque).
The resolver now returns a distinct, centrally-resolved runner cursor:
- a valid after_seq PAIRS with whatever after_event_id shape was supplied
— including an opaque runner id like event:2 that the journal parser
reads as a foreign run — so the paired runner cursor resumes at the seq
(never None / full replay, which would duplicate tokens and tool
events). after_seq is read directly (via _parse_run_journal_after_seq_value)
rather than through the after_event_id-first journal parser, which would
swallow a paired opaque runner id.
- a header-only opaque runner id (Last-Event-ID: event:2) is preserved
as-is so the runner resumes from it.
- a malformed or foreign explicit cursor WITHOUT a valid paired after_seq
still yields no runner cursor, blocking Last-Event-ID and replaying from
start — the r2 malformed-blocks-header guarantee is unchanged.
Regression tests pin the paired opaque id, header-only opaque id, and
malformed-with-valid-seq runner resumes, plus the unchanged
malformed-without-pair replay-from-start behavior.
Related hermes-webui/hermes-android#58
* fix(sse): normalize ahead cursor when the snapshot cutoff is unknown
Address PR #6886 r4 review (gate-fail). The ahead-cursor normalization
was guarded by `snapshot_cutoff_seq is not None`, so when the subscribe
snapshot had no parseable last_event_id (channel has not seen an
id-bearing frame yet), a positive ahead cursor (e.g. Last-Event-ID
run_1:999) was never normalized — it was installed verbatim as the live
dedup bound, filtering EVERY queued frame including the terminal
stream_end fence. The reconnect stalled on heartbeats with an empty SSE
body. origin/master ignored the header on this path and delivered the
events, so this was a new regression.
Fix: treat an unknown, non-truncated snapshot cutoff as fence 0 before
the ahead-cursor normalization, so a positive client cursor can't become
the live dedup bound with no cutoff to bound it — it fails closed to
replay-from-start, delivering the buffered events rather than silently
losing them. Frames dropped while the cutoff is unknown still cannot
prove gap coverage (cutoff None → not covered), so the recovery_control
fail-closed path is preserved.
Regression test: Last-Event-ID run_1:999 + snapshot_cutoff_seq=None
delivers queued events run_1:1 and run_1:2 exactly once, emits the
terminal stream_end, and releases the subscriber. Without the fix the
test stalls on heartbeats (pytest-timeout kills it), proving the stall.
Related hermes-webui/hermes-android#58
* Release — SSE Last-Event-ID resumption fix (#6886)
---------
Co-authored-by: Paladin173 <35980893+Paladin173@users.noreply.github.com>
Co-authored-by: n <a@n>
* fix(approval): stamp mirror token when creating no-run legacy mirror (#6008 follow-up)
In in-process (legacy) mode, a guarded command parks an _ApprovalEntry in
_gateway_queues whose .data has no run_id. submit_gateway_pending_mirror()
mirrors it into _pending for the UI in the `elif not exact_local_entry`
branch, but that branch never stamped _GATEWAY_MIRROR_TOKEN on the mirror.
_resolve_approval_legacy() binds a no-run mirror back to the parked
_ApprovalEntry via a token match (pending_token == gateway_token). Without
the mirror token the match fails, resolve_gateway_pending_local() is never
called and entry.event.set() never fires, so the agent thread stays blocked
even though the endpoint returns ok:true and the UI closes the card. Only a
~1.5s reconcile fallback re-creates the card with a token -> the FIRST click
on "Allow once" silently does nothing.
Stamp the token from the live gateway head at mirror-creation time, matching
the exact-local branch, so the first click unblocks the agent.
Adds a regression test reproducing the scenario.
* fix(approval): resolve non-head no-run mirrors by scanning all live producers
The first-click fix stamps the mirror token from the mirror's own producer.
But _resolve_approval_legacy() previously matched that token only against the
queue head (`_gateway_queues[sid][0]`), so a non-head mirror (multiple parked
producers, #7093) could not be resolved: the head's token differs from the
selected producer's, the match failed, and the agent thread stayed blocked.
Scan every live producer in _gateway_queues for a matching
_webui_mirror_token or approval_id, and stamp the resolved producer's
approval_id before delegating to resolve_gateway_pending_local(). Registered
producers keep head/tail ordering, so exact-producer isolation is preserved.
Adds tests/test_issue_legacy_first_click_repro.py::test_legacy_non_head_
producer_unblocks_only_its_producer, which reproduces the non-head scenario.
* fix(approval): fail closed on no-run mirror token binding (818fd2fd follow-up)
submit_gateway_pending_mirror() previously fell back to "first unclaimed
token" or the queue head when a no-run mirror's identity (request_id/
approval_id) didn't match any live producer. That let approving a
stale/foreign mirror silently resolve a different, unrelated live
producer than the one the user actually saw.
Bind the token only on an explicit identity match; otherwise leave the
mirror tokenless so it can't borrow another producer's slot. A tokenless
orphan is still created automatically for the legitimate #7093 case
(no live producer at all) via reconcile_gateway_pending_mirror_locked.
Adds two adversarial regression tests (stale request_id vs. a live
sibling, and an identity-less mirror with multiple producers parked) and
fixes an unrelated test that omitted request_id, unlike every real
_ApprovalEntry.
* fix(approval): don't let an unmatched no-run mirror mask a live producer
Follow-up to 63087a40. Failing closed on token binding stopped stale
mirror A from executing live producer B, but left A parked in _pending:
reconcile then deferred to the pre-existing no-run mirror and skipped
tokenizing B, so B never surfaced as the head. The real pending approval
became unactionable (409 in gateway mode, ok:true resolving nothing in
local mode) while an unresolvable card sat in front of it.
reconcile_gateway_pending_mirror_locked() now always tokenizes every live
no-run producer and derives live_token from the authoritative head
whenever _gateway_queues holds producers, so an unmatched tokenless
mirror is discarded instead of suppressing the real one. Retained
missing-producer mirrors survive only while no producer is live, keeping
the #7093 tokenless-orphan boundary intact.
Tests assert the displayed == resolved invariant: the authoritative
producer is the head, responding with the stale/identity-less id fails
without waking anything, and approving the displayed entry wakes exactly
its own producer. Three test_gateway_approval_runs_api.py cases that
encoded the old masking behavior are updated to the new invariant.
* docs(approval): correct reconcile comment on which mirrors survive
The comment added in 9c2ca104 claimed only the authoritative head's mirror
may survive while a producer is live. That contradicts the code it
documents and the non-head regression test: the `entry_token in
live_local_tokens` branch keeps a mirror bound to ANY live no-run
producer, which is exactly what makes a non-head mirror resolvable
(test_legacy_non_head_producer_unblocks_only_its_producer).
State the actual rule instead — a mirror survives only if bound to some
live producer's own token, the head's via live_token or a non-head
producer's via live_local_tokens — so unmatched and tokenless copies are
discarded without implying non-head mirrors are dropped too. Comment
only; no behavior change.
* Release — approval no-run mirror masking fix (#7086)
---------
Co-authored-by: VHSgunzo <wosganzo@gmail.com>
Co-authored-by: n <a@n>
* fix: suppress console windows for git subprocess calls on Windows
On Windows, every git.exe spawned by the WebUI (update checks,
version detection, workspace git info, rollback inspection,
worktree management) creates a visible console window. When
Windows Terminal is the default terminal, each short-lived git
process opens a new tab. Combined with the 60-second version-skew
monitor polling /api/settings and the boot-time update check, this
produces a flood of flashing terminal tabs.
Root cause: subprocess.run() calls in api/updates.py, api/rollback.py,
api/worktrees.py, and api/workspace.py did not pass
creationflags=CREATE_NO_WINDOW on Windows. api/workspace_git.py
already had the fix (#5692); these four files were missed.
Backend fix:
- Add _windows_hide_flags() helper to each affected module
(mirrors the existing pattern in api/workspace_git.py)
- Pass creationflags=_windows_hide_flags() to all git
subprocess.run() calls (10 call sites across 4 files)
Frontend fix (prevent timeout->retry cascades):
- Increase update-check timeout from 30s/60s to 300s (5 min)
- Increase /api/settings fetches from 30s to 60s
- Increase /api/git-info fetch from 30s to 60s
The backend git fetch to GitHub can take 15s per repo; with both
WebUI and Agent repos checked sequentially plus other git calls,
the total easily exceeds the old 30s default frontend timeout,
triggering the api() wrapper auto-retry (3 attempts) and
amplifying the git process flood.
* [verified] fix: address Windows git subprocess review
---------
Co-authored-by: ya1yi2fa3 <ya1yi2fa3@users.noreply.github.com>
Co-authored-by: n <a@n>