Commit Graph

1389 Commits

Author SHA1 Message Date
nesquena-hermes d0917f2b24 fix(#3104): correct dedup filename reporting + make zip-bomb cap testable
Two issues caught by the PR's own tests against the out-of-process test server:
1. Dedup reporting bug: after a filename collision the file was correctly
   written to e.g. report-1.pdf, but the JSON response reported the ORIGINAL
   name (safe_name) — now reports dest.name. (Real user-facing bug.)
2. Zip-bomb cap was untestable: the test monkeypatched _MAX_EXTRACTED_BYTES in
   the pytest process, which has no effect on the separate server process where
   extraction runs. Made the cap env-configurable (HERMES_WEBUI_MAX_EXTRACTED_MB,
   read at call time via _max_extracted_bytes(); defaults to 10x upload cap),
   set it to 5MB in the conftest server env, and rewrote the test to upload a
   compressible archive that genuinely extracts past the cap. Also asserts no
   partial extraction dir is left behind.

Plus lint: unused field_name loop var -> _field_name, unused os import in test.
2026-06-02 00:41:11 +00:00
nesquena-hermes ff81591e8e feat(workspace): add file upload + drag-drop with archive extraction (#3104, @antoniocarlos97ss) 2026-06-02 00:26:53 +00:00
nesquena-hermes 546afe8374 feat: add manual session title regeneration (#3223, @AJV20)
Closes #3106. Adds /api/session/title/regenerate endpoint + session-action
menu item. Preserves chronology (touch_updated_at=False), guards read-only
and imported sessions, syncs to state.db when Insights sync enabled.

Maintainer refinement (Opus SHOULD-FIX): scope the is_imported guard to the
regenerate action only instead of broadening the shared _isReadOnlySession()
helper, which also gates rename/pin/archive/move/fork. Matches the backend
403 guard. Test updated to assert the scoped shape.
2026-06-01 21:39:51 +00:00
nesquena-hermes c5d4806a64 fix(#3331): gate findings — empty-session profile retag, root-alias project filter, create-profile validation
Codex+Opus gate findings on the profile-scoping PR:
1. panels.js: retag S.session.profile on ANY profile switch (was inside the
   if(data.default_model) block, so model-less profile switches left a stale chip).
2. sessions.js: project-picker filter now mirrors the server's root-alias
   tolerance (default <-> renamed-root) so a server-approved 'default' project
   isn't hidden for a renamed-root session.
3. routes.py /api/projects/create: validate the optional client-supplied profile
   via _PROFILE_ID_RE before stamping (was trusting raw client input -> could
   create hidden cross-profile rows). Updated the PR's string-assertion test.
2026-06-01 21:05:32 +00:00
nesquena-hermes 3cf4d15dc6 fix: align project/session operations with session profile instead of global active profile (#3331)
Co-authored-by: PINKIIILQWQ <PINKIIILQWQ@users.noreply.github.com>
2026-06-01 20:57:40 +00:00
nesquena-hermes f50b4fc2fa fix: filter interrupted recovery control text from visible transcript (#3321)
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-01 20:07:16 +00:00
nesquena-hermes 329aae6979 fix: probe remote gateway via HERMES_API_URL before local fallback (#3312)
Rebased onto master + removed an unused 'io' import flagged by the ruff gate.

Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>
2026-06-01 19:38:00 +00:00
nesquena-hermes 76da1d144a fix(profiles): TOCTOU-safe _SKILLS_STATS_CACHE read via .get() (Opus pre-release nit on #3341) 2026-06-01 18:38:39 +00:00
nesquena-hermes de22c607bc stage-batch10: #3327 model-id normalize + #3334 RFC slice doc + #3341 profile skill counts
#3327 fix(reasoning): normalize custom-provider model ids for fallback heuristics
Co-authored-by: Carry00 <Carry00@users.noreply.github.com>

#3334 docs(rfc): mark run-adapter Slice 4f shipped, define Slice 4g gate
Co-authored-by: Michaelyklam <Michaelyklam@users.noreply.github.com>

#3341 fix(profiles): show enabled vs compatible skill counts
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-06-01 18:24:05 +00:00
nesquena-hermes bce1f9430b fix(#3102): don't let persisted watermark permanently drop future state.db recovery rows
Codex regression-gate finding: since Session.save() no longer auto-clears the
truncation_watermark, the unconditional 'timestamp > watermark' skip in
merge_session_messages_append_only became a permanent ceiling — a genuine future
state.db-only row (recovery/compaction, missed by the sidecar) would be silently
dropped from /api/session and model-context reconstruction forever. Only apply the
above-watermark skip while the sidecar has NOT advanced past the watermark. Preserves
the #2914 deleted-tail filtering (revert-verified). Adds 2 regression tests.
2026-06-01 18:14:48 +00:00
nesquena-hermes 778652713c Fix #2914: stop agent replaying edited/undone messages; sync context_messages truncation (#3102)
Co-authored-by: AlexeyDsov <AlexeyDsov@users.noreply.github.com>
2026-06-01 18:05:02 +00:00
kuishou68 23c51e8e64 fix: use Optional[T] for default None parameters in state_sync.py
Fixes type annotations where non-optional types (str, int) are used with
None defaults. All affected parameters now use Optional[T] = None.

Closes #3322
2026-06-01 17:20:17 +00:00
Sanjay Santhanam 92bddca3c7 fix: file manager falls back to state.db for external Telegram/CLI sessions (#3280) 2026-06-01 17:19:48 +00:00
AJV20 ec704356ac fix: hide attachment path markers in chat UI 2026-06-01 05:31:53 +00:00
AJV20 d8471a7d87 fix(profiles): avoid config import cycle 2026-06-01 05:05:16 +00:00
AJV20 ca99d12e04 fix: keep WebUI reply guidance language-neutral 2026-06-01 04:42:23 +00:00
AJV20 7a1b2bfe72 fix: carry WebUI reply polish into gateway chat 2026-06-01 04:42:23 +00:00
xz-dev 239f913485 Fix workspace open in browser inline sandbox 2026-06-01 04:42:05 +00:00
nesquena-hermes 7e9f674edf fix(#3263): provider-compatible default match — reject same-bare-name on different providers (Codex over-match MUST-FIX)
Prior round stripped provider prefixes from both sides and matched bare-only,
which over-matched: openai/gpt-4o would match default openrouter/gpt-4o. Now the
matcher compares bare model ids AND rejects when both sides identify DIFFERENT
providers (from provider/ prefix, @provider: qualifier, or the explicit provider
arg). Same-provider / unknown-session-provider still match. Added cross-provider
rejection regression tests.

Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
2026-06-01 01:19:49 +00:00
nesquena-hermes 4147e7f3df fix(#3263): provider-aware default-model match (Codex final-gate MUST-FIX)
The default-only context_length guard compared model.default to the session
model with exact string equality. But model.default and the session model can
be stored in equivalent-but-different shapes (bare 'claude-opus-4.8',
provider-prefixed 'anthropic/claude-opus-4.8', or '@anthropic:claude-opus-4.8').
An exact compare wrongly treats the actual default model as non-default and
drops its configured context_length cap for provider-prefixed configs.

Add api/routes._model_matches_configured_default(session_model, cfg_default,
provider) that normalizes all three shapes, and use it at all 6 guard sites
(routes resolver + the 5 api/streaming.py sites: live-usage snapshot, persistence
_skip_cc_cl, persistence fallback _apply_cfg_ctx, SSE-done _dropped_stale_cap_sse,
SSE fallback _apply_cfg_ctx). Imported function-scoped in streaming to avoid the
routes<->streaming module-level circular import. 10 helper unit tests + a
behavioral test that a prefixed default still receives its cap.

Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
2026-06-01 01:14:22 +00:00
nesquena-hermes 17e7794923 fix(#3263): init _skip_cc_cl/_cc_cl before compressor block (UnboundLocalError on no-compressor path) + relax brittle #1318 source-assertion
The threshold-rescale block runs unconditionally after the fallback and
references _skip_cc_cl/_cc_cl, which were only defined inside 'if _cc_for_save:'.
On the no-compressor path (fresh agent / interrupted stream) that raised
UnboundLocalError (caught by test_issue1857_usage_overwrite). Hoist both inits
above the block (no-op rescale when no compressor). Also widen the #1318
source-assertion test to accept the widened fallback gate (still asserts the
falsy-check invariant).

Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
2026-06-01 00:14:13 +00:00
nesquena-hermes 18a459726b fix(#3263): apply Codex dual-gate MUST-FIX — recompute persisted stale cap + rescale terminal threshold
Codex regression gate (+Opus, both independently) found the default-only guard
dropped the stale compressor cap but two sibling paths stayed inconsistent:
1. Per-turn persistence: fallback resolver only ran when context_length was
   falsy, so a previously-persisted stale 232K survived forever on non-default
   sessions. Now also runs when _skip_cc_cl, and rescales threshold_tokens to
   the recomputed real cap (or clears it).
2. Terminal SSE 'done' payload: re-emitted the stale compressor threshold, so
   messages.js overwrote S.lastUsage and the indicator reverted on stream end.
   Now rescales threshold to the resolved window when the stale cap was dropped.
Added 3 source-structure regression tests pinning both fixes; bumped the brittle
test_pr1341 distance limit 13000→15000 (+ noted it should become structural).

Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
2026-06-01 00:09:21 +00:00
allenliang2022 cba69cd415 fix(context): rescale threshold_tokens to real cap in live usage snapshot
The default-only guard corrected context_length to the real per-model cap
(e.g. 1M for claude-opus-4.7-1m) but left threshold_tokens pointing at the
ContextCompressor's stale value (computed from the global 232K cap → 197.2k
@ 85%). UI then showed 'auto-compress at 197.2k / 1M' which is misleading.

Rescale threshold_tokens by the real/orig ratio so the displayed trigger
reflects the actual window (e.g. ~850k @ 1M).

NOTE: this only corrects the SSE display payload. The real auto-compress
trigger lives inside ContextCompressor in hermes-agent (agent_init.py:1446
constructs it with the global cap). A full fix requires a parallel change
upstream — tracked separately.
2026-05-31 23:49:41 +00:00
allenliang2022 1263cf03cf perf(context): cache real per-model context_length in live usage snapshot
_live_usage_snapshot() runs on every metering tick (~10x/sec while streaming).
The #3256 default-only guard recomputed get_model_context_length() there on
every tick for non-default models, which does a config read + potential
metadata/network probe — freezing claude-opus-4.7-1m streams while the default
model (4.8) stayed fast (guard not triggered for it). Resolve the real cap at
most once per stream via _real_ctx_cache. Backend-only, no frontend changes.
2026-05-31 23:49:41 +00:00
allenliang2022 4785aa542a fix(context): re-apply default-only context_length guard after v0.51.185 upgrade
Re-applies cb0065eb + b34311b3 (context_length default-only guard) which were
dropped by the upgrade reset to v0.51.185. Fixes 4.7-1m context window showing
as stale global cap (232K) instead of real 1M metadata. Backend-only: touches
api/routes.py + api/streaming.py, zero frontend/render changes.
2026-05-31 23:49:40 +00:00
nesquena-hermes 5742a23836 fix: apply Opus+Codex gate findings — stale-stopped not configured (#1944), narrow #2905 markers, scope except
Both pre-release reviewers (Opus advisor + Codex regression gate) converged on
the same MUST-FIX:

- #3194: treating gateway_stale_stopped_state as 'configured' contradicted
  #1944 (a stopped root gateway should read like 'not configured' so the
  banner doesn't nag). Now ONLY stale-RUNNING metadata
  (reason=gateway_stale_running_state or gateway_state=='running') flips
  configured=True; stale-stopped falls through to bool(identity_map) like the
  genuinely-unconfigured case. Updated the test accordingly + added a
  stale-stopped no-regression test.

Opus follow-ups also applied:
- #2905: narrowed the populated-home markers to WebUI-only artifacts
  (webui/, webui/sessions, webui/settings.json), dropping config.yaml/auth.json
  so a long-time agent user installing WebUI fresh isn't wrongly diverted to
  the legacy %USERPROFILE%\.hermes (auth.json predates #2897 there).
- profiles._resolve_base_hermes_home(): narrowed except Exception -> ImportError
  so a real bug in the config helper still surfaces.

Adjacent suites green: #2840, #1879, gateway_status_agent_health (66 tests).
2026-05-31 19:12:30 +00:00
nesquena-hermes 5dc11ef977 fix: Windows upgrade state stranding (#2905) + gateway-configured banner on first deploy (#3194)
Two urgent breaking bugs that had no PR, combined into one hotfix.

#2905 (data-loss-class): v0.51.134 moved the Windows default Hermes home
from %USERPROFILE%\.hermes to %LOCALAPPDATA%\hermes (PR #2897) with no
migration, so upgrading Windows users opened the app to empty
sessions/pins/settings (data intact on disk, at an address the new build
no longer read). _platform_default_hermes_home() now prefers the populated
legacy home ONLY when the new location is not yet established —
non-destructive (no file moves) and self-healing on next launch.
profiles._resolve_base_hermes_home() delegates to the same config helper so
the active-profile pointer can never drift from STATE_DIR.

#3194: GET /api/gateway/status reported 'Gateway not configured' on a fresh
two-container Docker deploy because an alive=None + gateway_stale_running_state
health payload with an empty identity_map fell through to
configured=bool(identity_map)=False. The alive=None branch now treats a
payload carrying gateway metadata (gateway_state detail, or a stale-running/
stale-stopped reason) as configured.

Tests: +17 regression tests (11 for #2905 incl. full truth table + non-destructive
guard + POSIX no-op; 6 for #3194 incl. 5 no-regression guards). Full suite
7090 passed, 0 failed.

Closes #2905
Closes #3194
2026-05-31 19:12:30 +00:00
nesquena-hermes e58fb6a201 ci: forward-looking ruff lint gate (E9+F+B, new-code-only) (#3275)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-05-31 18:26:37 +00:00
nesquena-hermes ba7ae5786d harden: runner client rejects non-http(s) base_url + no redirect-follow (#3073)
Defense-in-depth flagged by both pre-release reviewers (Opus + Codex), both
non-blocking but cheap on a credential-handling surface:
- reject any non-http(s) HERMES_WEBUI_RUNNER_BASE_URL scheme at construction
  (a misconfigured file:// / ftp:// can never reach urlopen);
- route requests through an opener that does NOT follow 3xx redirects, so a
  misbehaving/compromised runner cannot smuggle the Bearer token to another host.
Both operator-misconfiguration-only (not user-reachable). +2 regression tests.

Co-authored-by: AJV20 <AJV20@users.noreply.github.com>
2026-05-31 17:49:26 +00:00
nesquena-hermes 33fbd9fc6d feat: add configured runner client boundary (#3073)
Opt-in HTTP runner-client boundary for the runner-local runtime adapter
(RFC hermes-run-adapter-contract / #1925, Slice 4c/4d). Default-OFF:
without HERMES_WEBUI_RUNNER_BASE_URL the factory preserves the bounded
'not configured' path; when set, WebUI acts only as a JSON HTTP client +
SSE bridge for start/observe/status/controls without owning runner maps.
New api/runner_client.py + additive _runner_* helpers in api/routes.py;
no change to the live _run_agent_streaming legacy path.

Co-authored-by: AJV20 <AJV20@users.noreply.github.com>
2026-05-31 17:37:11 +00:00
Pamnard 3a5cf6cda6 fix(updates): fall through when release tag is not ff-reachable
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.
2026-05-31 16:18:06 +00:00
nesquena-hermes 997eff58ae Merge PR #3249 into stage-batchE 2026-05-31 06:50:11 +00:00
allenliang2022 eddb17028d fix(updates): prevent startup crash on non-UTF-8 (GBK) locales
_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.
2026-05-31 14:32:48 +08:00
nesquena-hermes d3b69ab98b fix(security #3234): deny per-profile webui_state state subdirs (Codex review #11)
Per-profile WebUI state lives at <root>/webui_state (api/workspace.py), so
<base>/profiles/<name>/webui_state/sessions/*.json was reachable — it is not a
direct child of the profile root, so the prior deny-subdir loop missed it. Add
<root>/webui_state/<state-subdir> to the deny dirs for every Hermes root. Adds a
regression assertion (profile webui_state/sessions/*.json → 403).
2026-05-31 04:11:35 +00:00
nesquena-hermes a62d35a0b3 fix(security #3234): deny atomic-write temp auth/state files under Hermes roots (Codex review #10)
auth.py/passkeys.py write via tmp*.<name>.tmp sidecars then rename; deny those
suffixes (.sessions.tmp, .login_attempts.tmp, .passkeys.tmp, .passkey_challenges.tmp)
under Hermes roots so a momentary temp file can't be fetched via /api/media.
2026-05-31 04:05:47 +00:00
nesquena-hermes d1645ff803 fix(security #3234): deny passkeys.json + .passkey_challenges.json + .login_attempts.json (Codex review #9)
Codex direct probe found three more auth-state basenames under STATE_DIR that
/api/media still served: passkeys.json + .passkey_challenges.json (api/passkeys.py)
and .login_attempts.json (api/auth.py). Add them to _DENY_FILENAMES.
2026-05-31 03:58:19 +00:00
nesquena-hermes ce7a2c14db fix(security #3234): enumerate named-profile roots, drop wholesale profiles deny (Codex review #8)
Denying the whole <root>/profiles tree 403'd legitimate named-profile workspace
media (<base>/profiles/p1/workspace/shot.png). Fix: remove 'profiles' from
_DENY_SUBDIRS and instead enumerate each <root>/profiles/<name> directory as its
own Hermes root — so each profile's sensitive subdirs (sessions/memories/cron/
logs/checkpoints/backups) + secret filenames are denied, while that profile's
workspace/ is allowed via the carve-out. Adds a regression test: named-profile
workspace media serves, profile + sibling-profile auth.json stay 403.
2026-05-31 03:48:59 +00:00
nesquena-hermes 124044dc11 fix(security #3234): do not deny STATE_DIR wholesale — keep STATE_DIR/workspace media (Codex review #7)
The default workspace lives at STATE_DIR/workspace, so denying STATE_DIR itself
403'd legitimate workspace media. STATE_DIR is already in _hermes_roots, so its
sensitive subdirs (STATE_DIR/sessions, /memories, /profiles, etc.) are still
covered by the per-root subdir loop; direct sensitive files are still caught by
the filename denies. Drop the wholesale _state_dir deny. Adds a regression test
proving STATE_DIR/workspace/shot.png serves while STATE_DIR/sessions/*.json 403s.
2026-05-31 03:41:21 +00:00
nesquena-hermes 4fa051ea14 fix(security/#3234 + #3219): unify case-folded path checks, fix workspace over-block, protect raw <pre> (Codex review #6)
1. routes.py: hoist a single case-folded path helper (_norm/_within_ci/_equal_ci)
   used for ALL deny + carve-out comparisons (consistent macOS/Windows safety).
2. routes.py: split the deny into (a) dir-based denies that ALWAYS fire (even
   inside the active workspace — so a workspace overlapping a state dir cannot
   expose sessions/memories), and (b) filename denies relaxed only by the
   carve-out. Fix the over-block: a workspace that is a proper DESCENDANT of a
   Hermes root (e.g. STATE_DIR/workspace) is a legit project workspace and keeps
   the carve-out; only a root-itself / ancestor / $HOME / profiles / state-subdir
   workspace disables it.
3. ui.js: move the bare file:// media-stash pass after the raw-<pre> stash too,
   so file:// inside a raw <pre> block stays literal (not just fenced/inline code).
2026-05-31 03:24:35 +00:00
nesquena-hermes 5758d4a827 fix(security/#3234 + #3219): case-fold deny checks + make bare file:// rewrite code-fence-aware (Codex review #5)
1. api/routes.py: case-fold /api/media deny filename + dir containment checks
   (os.path.normcase + casefold) so STATE.DB / Sessions/ cannot bypass the
   state/secret deny on case-insensitive filesystems (macOS/Windows).
2. static/ui.js: move the bare file:// media-stash pass to run AFTER fenced-block
   and inline-code stashing, so a file:// inside a code block / backtick span
   stays literal text instead of becoming an auto-loaded <img>. The MEDIA: stash
   keeps its first-position precedent.
Adds behavioral renderer tests (real renderMd via node) for fenced + inline code
file:// staying literal, bare file:// becoming media, and anchors keeping the
link path. Closes the last Codex review items for #3234.
2026-05-31 03:17:10 +00:00
nesquena-hermes 26305c2ebb fix(security #3234): gate active-workspace carve-out against broad/internal roots (Codex review #4)
Codex round-4: the carve-out could re-open the hole if the active workspace is
pathologically set to a broad/internal root ($HOME, ~/.hermes, a profile root)
— get_last_workspace only checks is_dir(), so workspace=~/.hermes would serve
state.db. Gate the carve-out: disable it when the active workspace IS, CONTAINS,
or is CONTAINED BY any Hermes root, or is $HOME / a */profiles dir / a named
profile root / an internal state subdir. Adds a unit test proving state.db stays
403 when the active workspace is the Hermes home. Widen CSP-slice test window.
2026-05-31 03:08:10 +00:00
nesquena-hermes 343bad7263 fix(security #3234): redesign /api/media deny with active-workspace carve-out (Codex review #3)
Codex round-3 found the prior multi-profile hardening OVER-blocked: denying
STATE_DIR + base/profiles wholesale 403'd legitimate active-workspace media.
Redesign around a single principle: the ACTIVE WORKSPACE is the user's own
content (never deny), Hermes INTERNAL STATE lives outside any workspace (deny).
If target is inside the active workspace -> allow; else deny known secret/config
basenames + internal state subdirs across all Hermes roots. Also folds in Opus
defense-in-depth: adds cron/logs/checkpoints/backups subdirs +
gateway_state.json/channel_directory.json/jobs.json basenames. Adds an
over-block regression test (a /tmp artifact named settings_* still serves 200).
2026-05-31 02:55:53 +00:00
nesquena-hermes c514b32f61 fix(security #3234): cover ALL Hermes roots in /api/media deny-list (Codex review #2)
Under a named profile, process HERMES_HOME is ~/.hermes/profiles/<name> but the
allowlist still grants base ~/.hermes — so the prior deny (anchored only on the
active-profile root + STATE_DIR) left ~/.hermes/state.db and sibling-profile
secrets (~/.hermes/profiles/other/auth.json) reachable. Build deny roots from
every Hermes state root the allowlist accepts: active HERMES_HOME, base ~/.hermes,
api.profiles._DEFAULT_HERMES_HOME, and STATE_DIR; apply the state-subdir dir-denies
under each. Widen the CSP-slice structural test window to match.
2026-05-31 02:45:17 +00:00
nesquena-hermes 68a6099eaf fix(security #3234): harden /api/media deny-list (Codex review)
- Add state.db-wal / state.db-shm (SQLite sidecars carry the same data as state.db)
- Add google_token.json / google_client_secret.json (OAuth creds)
- Scope filename-based denies to files under HERMES_HOME / STATE_DIR so a
  legitimate workspace or /tmp media artifact named settings.json / config.yaml
  is not wrongly blocked.
Dir-based denies (state subdirs) remain unconditional.
2026-05-31 02:39:46 +00:00
nesquena-hermes 63bb2f9884 fix(security #3234): /api/media hard-denies WebUI state + secret/config files
Pre-release dual-gate (Codex + Opus) on #3219 surfaced that /api/media serves
files under the allowlisted Hermes home, including settings.json / state.db /
auth.json / config.yaml. #3219 makes this materially worse: pre-#3219 a bare
file:// URL in agent output rendered as inert text, but #3219 turns it into an
auto-loading <img src=/api/media?path=...> that fetches on render. Rather than
weaken #3219, harden the boundary at the route: hard-deny known secret/config
filenames and the WebUI state subdirs (sessions/memories/profiles + STATE_DIR)
before the allow/serve decision, covering every entry path (bare file://,
markdown anchors, MEDIA: tokens, session-token grants). Adds a live-server
regression test. Closes #3234.

Co-authored-by: AJV20 <24819659+AJV20@users.noreply.github.com>
2026-05-31 02:27:15 +00:00
nesquena-hermes dca4a2a7af fix(#3210 review): convert bare urlopen TimeoutError to ValueError in _joplin_api_get
Codex+Opus pre-release gate both flagged: TimeoutError is now in the
consolidated _CLIENT_DISCONNECT_ERRORS dispatch set, so a bare socket-connect
TimeoutError from Joplins urlopen(timeout=8) — which is NOT always URLError-
wrapped — would escape _handle_notes_search and be swallowed by the dispatch
disconnect handler as a fake client disconnect (silent empty response, no log).
Catch (URLError, TimeoutError) at the route so it surfaces as a clean
"not reachable" ValueError -> JSON error. Adds a regression test.

Co-authored-by: someaka <someaka@users.noreply.github.com>
2026-05-31 01:57:29 +00:00
Ed 79f526b1b2 fix(helpers): use ssl.SSLError instead of broad OSError in disconnect tuple
OSError is too broad — it masks real errors like file-not-found.
ssl.SSLError specifically catches SSL-level disconnects without
swallowing unrelated OSError subtypes.

Closes the test_excludes_broad_oserror CI failure.
2026-05-31 01:56:39 +00:00
Ed 78652ecdea fix: consolidate _CLIENT_DISCONNECT_ERRORS, remove scratch file
Address review feedback from @nesquena-hermes on PR #3210:

1. Deduplicate _CLIENT_DISCONNECT_ERRORS:
   - Single authoritative definition in api/helpers.py
   - api/routes.py now imports from api.helpers instead of defining
     its own copy with different membership
   - Unified tuple uses OSError (covers ssl.SSLError since it
     subclasses OSError) — broad socket-level disconnect coverage

2. Remove github-search-report.md:
   - Research scratch output that doesn't belong in the repo root
   - Content belongs in PR description or a gist

3. Docstring improvement:
   - Added comment explaining why OSError covers ssl.SSLError
   - Documents the errno-level socket errors caught by OSError
2026-05-31 01:56:39 +00:00
Ed 59b7a3ac94 fix: eliminate silent failures in client disconnect handling
- api/helpers.py: _safe_write() now logs disconnects at debug level
  instead of silently passing. No more invisible errors.

- server.py: Restructure exception handlers to catch
  _CLIENT_DISCONNECT_ERRORS first, then Exception. Remove the
  isinstance() filter inside except Exception (LBYL anti-pattern).
  The 500-response fallback now catches _CLIENT_DISCONNECT_ERRORS
  separately (expected) and logs unexpected failures via
  traceback.print_exc() instead of bare except Exception: pass.

- tests/test_broken_pipe_cascade.py: Add coverage for SSL/Timeout
  disconnect routing and 500-response safety (both disconnect
  survival and unexpected error logging).
2026-05-31 01:56:39 +00:00
someaka 2cee44b1b2 fix(helpers): catch client disconnect errors in response write path
Extract _safe_write() helper that wraps end_headers() + wfile.write()
in try/except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError,
TimeoutError, ssl.SSLError).  Both j() and t() now use _safe_write()
instead of raw wfile calls.

Fixes cascading BrokenPipeError + SSL BAD_LENGTH crash when a client
disconnects mid-response and the error handler tries to write a 500
status through the same broken socket.
2026-05-31 01:56:39 +00:00