Release ON: show_cli_sessions default-on for new installs (#3988) + symlink delete/rename guard (#4217) (#4230)

* fix(#3988): default show_cli_sessions to true for new installs, grandfather existing OFF

Combines the best of #3997 (rodboev) and #4222 (Sanjays2402):
- config.py default flipped True so NEW installs surface CLI/TUI/Telegram/
  Discord/Hermes One sessions in the sidebar without hunting for the toggle.
- load_settings() backfill grandfathers ESTABLISHED installs OFF: when the saved
  settings predate the flip (onboarding_completed True + key absent), pin False so
  an existing user's sidebar doesn't silently change. Fresh installs get True.
- Default-true hydration at every read site (config.py / boot.js / panels.js
  checkbox) via the !==false idiom — no config-ON-but-UI-OFF mismatch (#4006 class).
- 10 tests: config default, bool-key round-trip, grandfather matrix
  (fresh/mid-onboarding/established-absent/explicit-true/explicit-false/
  save-preserves-grandfather), and both frontend hydration sites.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>

* fix(#4217): guard /api/file/delete and /api/file/rename against symlink targets

* chore(release): stamp v0.51.427 (Release ON) for #3988 + #4217

* chore: drop unused imports (ruff F401) in absorbed test files

* fix(#4217,#3988): address review — dangling-symlink ordering + boot.js catch fallback

Greptile review on #4230 + the related-PR reviews surfaced two fixes on the
shipped code:
1. (#4217) _handle_file_delete/_handle_file_rename checked target.exists()
   BEFORE is_symlink(), so a DANGLING workspace symlink resolved to a missing
   target -> misclassified as 404 'File not found' and left permanently
   undeletable/unrenameable. Moved the no-follow is_symlink() guard before the
   follow-based exists() check in both handlers. +2 dangling-symlink tests.
2. (#3988) boot.js settings-load-FAILURE catch block hardcoded
   _showCliSessions=false, inconsistent with the new True default (the #4006
   catch-block-fallback class). Now defaults true. +1 test pinning the fallback.

Both confirmed by the new tests (20/20 in the two files).

* fix(#3988,#4217): address Opus review — broaden grandfather signal + move-handler parity

Opus #4230 ship-review surfaced 2 LOW findings, both now fixed in-line:
1. (#3988) The grandfather keyed only on onboarding_completed, so a CLI-configured
   user who tweaked a WebUI setting but never ran the wizard would get flipped ON
   (and onboarding.py later auto-persisting onboarding_completed=True would make it
   permanent). Now: established = show_cli_sessions absent AND (onboarding_completed
   OR any persisted setting other than a falsy onboarding flag). A genuinely new /
   still-mid-onboarding file still falls through to True. +1 test.
2. (#4217) _handle_file_move still checked exists() before is_symlink() — the same
   dangling-symlink-404 bug just fixed in delete/rename. Reordered for parity so a
   dangling symlinked source is rejected 400, not 404.

22 tests across the two files pass; ruff clean.

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>
This commit is contained in:
nesquena-hermes 2026-06-14 23:06:22 -07:00 committed by GitHub
parent e7e856cd79
commit ed5afd2ae3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 483 additions and 11 deletions

View file

@ -3,6 +3,16 @@
## [Unreleased]
## [v0.51.427] — 2026-06-15 — Release ON (CLI sessions on by default for new installs #3988 + symlink delete/rename guard #4217)
### Changed
- **CLI / TUI / messaging sessions now appear in the sidebar by default for new installs (#3988).** `show_cli_sessions` now defaults to `True`, so sessions from the CLI, TUI, Telegram, Discord, and the Hermes One desktop app show up in the WebUI sidebar without users having to discover the toggle in Settings. **Existing installs are grandfathered:** a user who already completed onboarding and never opted in keeps their previous (hidden) behavior — the default change only affects fresh installs. The toggle remains in Settings → Sessions for anyone who wants to change it.
### Fixed
- **Deleting or renaming a workspace symlink no longer destroys the file or directory it points to (#4217).** `/api/file/delete` and `/api/file/rename` resolved the final symlink before operating on the target, so a standard delete/rename against a symlink name acted on the real (possibly out-of-workspace) target. Both handlers now reject a symlinked path with a 400, matching the guard the move handler already had. (#4217)
## [v0.51.426] — 2026-06-15 — Release OM (custom-provider model-prefix routing fix, #4210)
### Fixed

View file

@ -6505,7 +6505,7 @@ _SETTINGS_DEFAULTS = {
"hide_empty_state_suggestions": False, # hide the default new-chat suggestion buttons
"show_tps": False, # show tokens-per-second chip in assistant message headers
"fade_text_effect": False, # animate newly streamed words with a lightweight fade-in effect
"show_cli_sessions": False, # merge CLI sessions from state.db into the sidebar
"show_cli_sessions": True, # merge CLI/TUI/messaging sessions from state.db into the sidebar by default (#3988); established installs are grandfathered OFF by the load_settings backfill
"show_cron_sessions": False, # surface cron sessions in the sidebar (subordinate to show_cli_sessions)
"show_previous_messaging_sessions": False, # show older Telegram/Discord/etc. reset segments
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
@ -6647,6 +6647,26 @@ def load_settings() -> dict:
if k not in _SETTINGS_LEGACY_DROP_KEYS
}
)
# Grandfather established installs OFF for show_cli_sessions (#3988).
# The default flipped True so NEW users see CLI/TUI/messaging
# sessions without hunting for the toggle — but an existing user
# who never opted in should not have their sidebar silently change.
# Treat the install as established (and pin the old False default)
# when show_cli_sessions is absent AND the file already carries
# real user state — either onboarding was completed, or some
# setting OTHER than a not-yet-completed onboarding flag has been
# persisted. Keying on "has saved user state" (not just
# onboarding_completed) also covers a CLI-configured user who
# tweaked a WebUI setting before running the wizard. A genuinely
# new / still-mid-onboarding file falls through to the True default.
_established_keys = [
k for k in stored
if k not in ("show_cli_sessions", "onboarding_completed")
]
if "show_cli_sessions" not in stored and (
bool(stored.get("onboarding_completed")) or _established_keys
):
settings["show_cli_sessions"] = False
except Exception:
logger.debug("Failed to load settings from %s", SETTINGS_FILE)
settings["theme"], settings["skin"] = _normalize_appearance(

View file

@ -14723,6 +14723,13 @@ def _handle_file_delete(handler, body):
try:
ws_root = Path(s.workspace)
target = safe_resolve(ws_root, body["path"])
# Reject a symlinked entry BEFORE the follow-based exists() check: a
# dangling symlink resolves to a missing target, so an exists()-first
# order would misclassify it as 404 "File not found" and leave it
# permanently undeletable. is_symlink() is a no-follow lstat on the
# lexically-requested path, so it catches both live and dangling links.
if (ws_root / body["path"]).is_symlink():
return bad(handler, "Cannot delete a symlinked entry")
if not target.exists():
return bad(handler, "File not found", 404)
if target.is_dir():
@ -14803,6 +14810,11 @@ def _handle_file_rename(handler, body):
ws_root = Path(s.workspace)
ws_root_resolved = ws_root.resolve()
source = safe_resolve(ws_root, body["path"])
# Reject a symlinked entry BEFORE the follow-based exists() check (see
# _handle_file_delete): a dangling symlink would otherwise 404 and stay
# unrenameable. is_symlink() is a no-follow lstat on the requested path.
if (ws_root / body["path"]).is_symlink():
return bad(handler, "Cannot rename a symlinked entry")
if not source.exists():
return bad(handler, "File not found", 404)
new_name = body["new_name"].strip()
@ -14838,15 +14850,18 @@ def _handle_file_move(handler, body):
# returning a confusing 400 for a move that actually happened.
ws_root_resolved = ws_root.resolve()
source = safe_resolve(ws_root, body["path"])
if not source.exists():
return bad(handler, "File not found", 404)
# Reject a symlinked SOURCE entry. safe_resolve() follows the final
# symlink, so source.name/source.parent would point at the link's
# TARGET, not the dragged entry — moving link.txt would silently move
# dir/real.txt and leave link.txt dangling. Detect the symlink on the
# lexically-requested final component (lstat, no-follow) and refuse.
# Reject a symlinked SOURCE entry BEFORE the follow-based exists() check.
# safe_resolve() follows the final symlink, so source.name/source.parent
# would point at the link's TARGET, not the dragged entry — moving
# link.txt would silently move dir/real.txt and leave link.txt dangling.
# Detect the symlink on the lexically-requested final component (lstat,
# no-follow) and refuse; running this before exists() also means a
# dangling symlink is rejected (400) rather than misclassified as 404
# (matches the delete/rename ordering).
if (ws_root / body["path"]).is_symlink():
return bad(handler, "Cannot move a symlinked entry")
if not source.exists():
return bad(handler, "File not found", 404)
dest_dir_raw = (body.get("dest_dir") or ".").strip()
if not dest_dir_raw:
dest_dir_raw = "."

View file

@ -1848,7 +1848,7 @@ function applyBotName(){
applyEmptyStateSuggestionPref();
window._showTps=!!s.show_tps;
window._fadeTextEffect=!!s.fade_text_effect;
window._showCliSessions=!!s.show_cli_sessions;
window._showCliSessions=s.show_cli_sessions!==false;
window._showPreviousMessagingSessions=!!s.show_previous_messaging_sessions;
window._soundEnabled=!!s.sound_enabled;
window._notificationsEnabled=!!s.notifications_enabled;
@ -1959,7 +1959,7 @@ function applyBotName(){
applyEmptyStateSuggestionPref();
window._showTps=false;
window._fadeTextEffect=false;
window._showCliSessions=false;
window._showCliSessions=true; // settings-load failed: mirror the True config default (#3988)
window._soundEnabled=false;
window._notificationsEnabled=false;
window._whatsNewSummaryEnabled=false;

View file

@ -6773,7 +6773,7 @@ async function loadSettingsPanel(){
const apiRedactCb=$('settingsApiRedact');
if(apiRedactCb){apiRedactCb.checked=settings.api_redact_enabled!==false;apiRedactCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const showCliCb=$('settingsShowCliSessions');
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
if(showCliCb){showCliCb.checked=settings.show_cli_sessions!==false;showCliCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const showCronCb=$('settingsShowCronSessions');
if(showCronCb){
showCronCb.checked=!!settings.show_cron_sessions;

View file

@ -0,0 +1,175 @@
"""show_cli_sessions default-True + grandfather migration + hydration consistency (#3988).
The default for ``show_cli_sessions`` flipped to **True** so that NEW installs
surface CLI / TUI / Telegram / Discord / Hermes One sessions in the WebUI sidebar
without users having to discover the toggle in Settings (the surprise described in
#3988).
Two correctness properties this pins:
1. **Grandfather established installs OFF.** An existing user who already completed
onboarding and never opted in must NOT have their sidebar silently change. The
``load_settings()`` backfill pins ``show_cli_sessions=False`` when the saved
settings predate the flip (``onboarding_completed`` True, key absent). Fresh
installs (no settings file / onboarding not completed) get the True default.
2. **Default-true hydration at EVERY read site.** config.py, boot.js, and the
panels.js Settings checkbox must all default an *absent* value to True — a
``!!value`` coerce would default the True setting OFF for a user with no saved
value (the #4006 default-mismatch class), showing config-ON-but-UI-OFF.
Combines + supersedes the approaches in #3997 (rodboev) and #4222 (Sanjays2402).
"""
import json
import pathlib
import pytest
import api.config as config
ROOT = pathlib.Path(__file__).resolve().parent.parent
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
# ── 1. config.py default ──────────────────────────────────────────────────
def test_show_cli_sessions_default_is_true_in_config():
assert config._SETTINGS_DEFAULTS.get("show_cli_sessions") is True, (
"show_cli_sessions must default to True so CLI/TUI/messaging sessions are "
"visible by default for new installs (#3988)"
)
def test_show_cli_sessions_in_bool_keys():
assert "show_cli_sessions" in config._SETTINGS_BOOL_KEYS, (
"show_cli_sessions must be in _SETTINGS_BOOL_KEYS so it round-trips as a bool"
)
# ── 2. Grandfather migration in load_settings() ───────────────────────────
@pytest.fixture()
def settings_file(tmp_path, monkeypatch):
"""Point config.SETTINGS_FILE at a temp file for isolated load_settings tests."""
f = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", f)
return f
def _write(f, data):
f.write_text(json.dumps(data), encoding="utf-8")
def test_fresh_install_no_settings_file_defaults_on(settings_file):
"""No settings file at all (brand-new install) → True (the new default)."""
assert not settings_file.exists()
loaded = config.load_settings()
assert loaded["show_cli_sessions"] is True
def test_fresh_install_onboarding_not_completed_defaults_on(settings_file):
"""Settings file with ONLY a not-yet-completed onboarding flag (still
mid-first-run, no other user state) → True."""
_write(settings_file, {"onboarding_completed": False})
loaded = config.load_settings()
assert loaded["show_cli_sessions"] is True
def test_established_cli_user_without_onboarding_flag_grandfathered_off(settings_file):
"""A CLI-configured user who tweaked a WebUI setting (e.g. theme) but never
ran the onboarding wizard is still an ESTABLISHED install — grandfather OFF.
Keying the grandfather only on onboarding_completed would miss this user and
silently flip their sidebar on (Opus #4230 review finding). Any persisted
setting other than a falsy onboarding flag marks the install established.
"""
_write(settings_file, {"onboarding_completed": False, "theme": "dark"})
loaded = config.load_settings()
assert loaded["show_cli_sessions"] is False, (
"an install with persisted user settings (even without onboarding "
"completed) must be grandfathered OFF, not flipped on"
)
def test_established_install_absent_key_grandfathered_off(settings_file):
"""Established install (onboarding done) that never set the key → stays OFF.
This is the core grandfather guarantee: an existing user's sidebar must not
silently start showing CLI/messaging sessions after the default flips.
"""
_write(settings_file, {"onboarding_completed": True, "theme": "dark"})
loaded = config.load_settings()
assert loaded["show_cli_sessions"] is False, (
"an onboarding-completed install with no saved show_cli_sessions must be "
"grandfathered OFF, not flipped on by the new default"
)
def test_explicit_true_is_respected(settings_file):
"""A user who explicitly opted in keeps it ON."""
_write(settings_file, {"onboarding_completed": True, "show_cli_sessions": True})
loaded = config.load_settings()
assert loaded["show_cli_sessions"] is True
def test_explicit_false_is_respected(settings_file):
"""A user who explicitly turned it off keeps it OFF."""
_write(settings_file, {"onboarding_completed": True, "show_cli_sessions": False})
loaded = config.load_settings()
assert loaded["show_cli_sessions"] is False
def test_established_install_persists_grandfather_through_save(settings_file):
"""save_settings() does load_settings() first, so the grandfathered False is
carried into the persisted blob — no window where a save flips an established
user ON. (save_settings writes the full merged dict.)"""
_write(settings_file, {"onboarding_completed": True})
# A save that doesn't mention show_cli_sessions must not flip it on.
config.save_settings({"theme": "light"})
persisted = json.loads(settings_file.read_text(encoding="utf-8"))
assert persisted.get("show_cli_sessions") is False, (
"save_settings must preserve the grandfathered OFF state for established "
"installs, never silently flip it on"
)
# ── 3. Default-true hydration at every frontend read site ──────────────────
# (string-pins; guard against the #4006 !!-coerce default-mismatch class)
def test_boot_hydration_defaults_true_when_setting_absent():
src = _read("static/boot.js")
assert "window._showCliSessions=s.show_cli_sessions!==false" in src, (
"boot.js must default _showCliSessions True when the saved value is absent"
)
assert "window._showCliSessions=!!s.show_cli_sessions" not in src, (
"boot.js must not use !!s.show_cli_sessions — that defaults the True "
"setting OFF for users with no saved value (#4006 mismatch class)"
)
def test_boot_settings_load_failure_fallback_defaults_true():
"""The settings-load-FAILED catch block must also default _showCliSessions
True, mirroring the config default — otherwise a transient settings-read
error silently hides CLI sessions (the #4006 catch-block-fallback class the
autoScrollFollow fix pinned)."""
src = _read("static/boot.js")
assert "window._showCliSessions=false" not in src, (
"the settings-load-failure fallback must not hardcode _showCliSessions "
"false — it should mirror the True default"
)
def test_settings_checkbox_renders_checked_by_default():
src = _read("static/panels.js")
assert "showCliCb.checked=settings.show_cli_sessions!==false" in src, (
"the show-CLI-sessions checkbox must default checked (!== false), matching "
"the True config default"
)
assert "showCliCb.checked=!!settings.show_cli_sessions" not in src, (
"panels.js must not use !!settings.show_cli_sessions for the checkbox — "
"that renders it unchecked by default, contradicting the config default"
)

View file

@ -0,0 +1,252 @@
"""Regression tests for #4217 — symlink guards on /api/file/delete and /api/file/rename."""
import os
import pytest
import api.routes as routes
@pytest.fixture()
def workspace(tmp_path):
"""Yield a real temporary workspace directory and clean up after."""
ws = tmp_path / "ws"
ws.mkdir()
yield ws
class _FakeHandler:
pass
def _patch_routes(ws):
"""Monkey-patch routes helpers so handlers run without an HTTP server."""
class _S:
workspace = str(ws)
cap = {}
orig_j = routes.j
orig_bad = routes.bad
orig_get = routes.get_session_for_file_ops
routes.j = lambda h, o: (cap.__setitem__("ok", o), True)[1]
routes.bad = lambda h, m, c=400: (cap.__setitem__("bad", (m, c)), True)[1]
routes.get_session_for_file_ops = lambda sid: _S()
return cap, (orig_j, orig_bad, orig_get)
def _restore_routes(originals):
routes.j, routes.bad, routes.get_session_for_file_ops = originals
# ── DELETE symlink guards ────────────────────────────────────────────────
def test_delete_workspace_symlink_to_dir_rejected(workspace):
real_dir = workspace / "real_dir"
real_dir.mkdir()
link = workspace / "link_to_dir"
try:
os.symlink(str(real_dir), str(link))
except (OSError, NotImplementedError):
pytest.skip("platform does not support symlinks")
cap, orig = _patch_routes(workspace)
try:
routes._handle_file_delete(
_FakeHandler(),
{"session_id": "x", "path": "link_to_dir", "recursive": True},
)
finally:
_restore_routes(orig)
assert "bad" in cap, f"expected 400, got {cap}"
assert cap["bad"][1] == 400
assert "Cannot delete a symlinked entry" in cap["bad"][0]
assert real_dir.exists(), "real target directory was deleted through the symlink"
def test_delete_workspace_symlink_to_file_rejected(workspace):
real_file = workspace / "real_file.txt"
real_file.write_text("important")
link = workspace / "link_to_file.txt"
try:
os.symlink(str(real_file), str(link))
except (OSError, NotImplementedError):
pytest.skip("platform does not support symlinks")
cap, orig = _patch_routes(workspace)
try:
routes._handle_file_delete(
_FakeHandler(),
{"session_id": "x", "path": "link_to_file.txt"},
)
finally:
_restore_routes(orig)
assert "bad" in cap, f"expected 400, got {cap}"
assert cap["bad"][1] == 400
assert "Cannot delete a symlinked entry" in cap["bad"][0]
assert real_file.exists(), "real target file was deleted through the symlink"
# ── RENAME symlink guards ───────────────────────────────────────────────
def test_rename_workspace_symlink_to_dir_rejected(workspace):
real_dir = workspace / "real_dir"
real_dir.mkdir()
link = workspace / "link_to_dir"
try:
os.symlink(str(real_dir), str(link))
except (OSError, NotImplementedError):
pytest.skip("platform does not support symlinks")
cap, orig = _patch_routes(workspace)
try:
routes._handle_file_rename(
_FakeHandler(),
{"session_id": "x", "path": "link_to_dir", "new_name": "renamed"},
)
finally:
_restore_routes(orig)
assert "bad" in cap, f"expected 400, got {cap}"
assert cap["bad"][1] == 400
assert "Cannot rename a symlinked entry" in cap["bad"][0]
def test_rename_workspace_symlink_to_file_rejected(workspace):
real_file = workspace / "real_file.txt"
real_file.write_text("important")
link = workspace / "link_to_file.txt"
try:
os.symlink(str(real_file), str(link))
except (OSError, NotImplementedError):
pytest.skip("platform does not support symlinks")
cap, orig = _patch_routes(workspace)
try:
routes._handle_file_rename(
_FakeHandler(),
{"session_id": "x", "path": "link_to_file.txt", "new_name": "renamed.txt"},
)
finally:
_restore_routes(orig)
assert "bad" in cap, f"expected 400, got {cap}"
assert cap["bad"][1] == 400
assert "Cannot rename a symlinked entry" in cap["bad"][0]
# ── Non-symlink operations still work ───────────────────────────────────
def test_delete_real_dir_still_works(workspace):
real_dir = workspace / "deleteme"
real_dir.mkdir()
(real_dir / "child.txt").write_text("x")
cap, orig = _patch_routes(workspace)
try:
routes._handle_file_delete(
_FakeHandler(),
{"session_id": "x", "path": "deleteme", "recursive": True},
)
finally:
_restore_routes(orig)
assert "ok" in cap, f"expected success, got {cap}"
assert cap["ok"]["ok"] is True
assert not real_dir.exists()
def test_rename_real_file_still_works(workspace):
real_file = workspace / "old_name.txt"
real_file.write_text("content")
cap, orig = _patch_routes(workspace)
try:
routes._handle_file_rename(
_FakeHandler(),
{"session_id": "x", "path": "old_name.txt", "new_name": "new_name.txt"},
)
finally:
_restore_routes(orig)
assert "ok" in cap, f"expected success, got {cap}"
assert cap["ok"]["ok"] is True
assert not real_file.exists()
assert (workspace / "new_name.txt").exists()
# ── Existing move guard pinned ──────────────────────────────────────────
def test_move_workspace_symlink_still_rejected(workspace):
real_file = workspace / "real.txt"
real_file.write_text("data")
dest = workspace / "dest"
dest.mkdir()
link = workspace / "link.txt"
try:
os.symlink(str(real_file), str(link))
except (OSError, NotImplementedError):
pytest.skip("platform does not support symlinks")
cap, orig = _patch_routes(workspace)
try:
routes._handle_file_move(
_FakeHandler(),
{"session_id": "x", "path": "link.txt", "dest_dir": "dest"},
)
finally:
_restore_routes(orig)
assert "bad" in cap, f"expected 400, got {cap}"
assert cap["bad"][1] == 400
assert "Cannot move a symlinked entry" in cap["bad"][0]
# ── Dangling symlinks: guard must fire BEFORE the exists() 404 (#4230 review) ──
def test_delete_dangling_symlink_rejected_not_404(workspace):
"""A dangling workspace symlink (target removed) must be rejected by the
symlink guard (400), not misclassified as 404 'File not found' and left
permanently undeletable. The is_symlink() check must precede exists()."""
link = workspace / "dangling_del"
try:
os.symlink(str(workspace / "gone_target"), str(link))
except (OSError, NotImplementedError):
pytest.skip("platform does not support symlinks")
cap, orig = _patch_routes(workspace)
try:
routes._handle_file_delete(
_FakeHandler(),
{"session_id": "x", "path": "dangling_del", "recursive": True},
)
finally:
_restore_routes(orig)
assert "bad" in cap, f"expected 400, got {cap}"
assert cap["bad"][1] == 400
assert "Cannot delete a symlinked entry" in cap["bad"][0]
def test_rename_dangling_symlink_rejected_not_404(workspace):
"""A dangling workspace symlink must be rejected by the rename guard (400),
not 404'd before the guard fires."""
link = workspace / "dangling_ren"
try:
os.symlink(str(workspace / "gone_target"), str(link))
except (OSError, NotImplementedError):
pytest.skip("platform does not support symlinks")
cap, orig = _patch_routes(workspace)
try:
routes._handle_file_rename(
_FakeHandler(),
{"session_id": "x", "path": "dangling_ren", "new_name": "renamed"},
)
finally:
_restore_routes(orig)
assert "bad" in cap, f"expected 400, got {cap}"
assert cap["bad"][1] == 400
assert "Cannot rename a symlinked entry" in cap["bad"][0]