From f4390cb20a4a2f6cfaec7c17484b2203cfe09291 Mon Sep 17 00:00:00 2001
From: Adolanium <94890352+Adolanium@users.noreply.github.com>
Date: Sun, 25 Oct 2026 22:46:01 +0200
Subject: [PATCH 001/114] fix(webapp): detach a host terminal when close meets
a full input queue
The host terminal reader blocked in inbox.put once the input queue was full, so a tab close never reached the receive loop. ended stayed unset, a write waiting on the program was never cancelled, and the shell stayed attached until the child read or another client attached, holding one of the 16 slots.
The reader now keeps receiving while a put waits for room. Closing the tab sets ended immediately and cancels the first write that has to wait for the program. Keystrokes already sent stay queued in order, so a slow program that is reading them still takes them.
---
hermes_cli/web_host_terminal_sessions.py | 121 ++++++++++++++++--
.../test_host_terminal_viewer_ownership.py | 108 +++++++++++++++-
2 files changed, 216 insertions(+), 13 deletions(-)
diff --git a/hermes_cli/web_host_terminal_sessions.py b/hermes_cli/web_host_terminal_sessions.py
index 61f2d110b9..d0c48b7d7a 100644
--- a/hermes_cli/web_host_terminal_sessions.py
+++ b/hermes_cli/web_host_terminal_sessions.py
@@ -9,6 +9,7 @@ refused and the reaper closes it.
from __future__ import annotations
import asyncio
+from collections import deque
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass
@@ -32,6 +33,10 @@ _MAX_SHELLS = 16
# Inbound frames buffered while one batch is written (backpressure bound).
_INPUT_BATCH_FRAMES = 64
TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{43}$")
+# Puts abandoned because this task is already cancelling. Awaiting them here is
+# cancelled again before they finish; holding them until the callback reads the
+# result keeps "exception was never retrieved" off the loop.
+_DROPPED_TASKS: set[asyncio.Task] = set()
class TerminalDenied(Exception):
@@ -259,22 +264,120 @@ async def _admit(ws: WebSocket, registry: HostTerminalRegistry):
return session, owner
+def _read_dropped_task(task: asyncio.Task) -> None:
+ _DROPPED_TASKS.discard(task)
+ if not task.cancelled():
+ task.exception()
+
+
+def _drop_task(task: asyncio.Task) -> None:
+ """Cancel ``task`` without awaiting it. Safe while this task is itself cancelling."""
+ if not task.done():
+ task.cancel()
+ _DROPPED_TASKS.add(task)
+ task.add_done_callback(_read_dropped_task)
+
+
+class _SocketTail:
+ """Socket reads that stay ahead of a full inbox.
+
+ The inbox bound is backpressure for a live viewer. A put that waits for room must
+ not be the only thing the reader is doing: the tab's close is the next socket
+ message, possibly behind further keystrokes, and it has to surface while that
+ put is blocked. Frames already pulled are kept, in order, so a program that is
+ still reading takes them. They are not dropped to make the queue move.
+ """
+
+ def __init__(self, ws: WebSocket):
+ self._ws = ws
+ self._ahead: deque = deque()
+ self._recv: asyncio.Task | None = asyncio.create_task(ws.receive())
+
+ def close(self) -> None:
+ recv, self._recv = self._recv, None
+ if recv is not None:
+ _drop_task(recv)
+
+ def pending_receive(self) -> asyncio.Task | None:
+ return self._recv
+
+ async def next_message(self):
+ """The next data frame, or None once the socket has disconnected."""
+ if self._ahead:
+ return self._ahead.popleft()
+ if self._recv is None:
+ return None
+ msg = await self._recv
+ if msg.get("type") == "websocket.disconnect":
+ self._recv = None
+ return None
+ self._recv = asyncio.create_task(self._ws.receive())
+ return msg
+
+ def saw_disconnect(self) -> bool:
+ """Consume a receive that has already finished. Data stays queued ahead."""
+ recv = self._recv
+ if recv is None or not recv.done():
+ return False
+ msg = recv.result()
+ if msg.get("type") == "websocket.disconnect":
+ self._recv = None
+ return True
+ self._ahead.append(msg)
+ self._recv = asyncio.create_task(self._ws.receive())
+ return False
+
+
+async def _put_frame(inbox: asyncio.Queue, tail: _SocketTail, ended: asyncio.Future, msg) -> None:
+ """Queue ``msg``. A full inbox still leaves the socket readable.
+
+ On disconnect, ``ended`` is set before this put finishes, so a write waiting on
+ the program is cancelled even though the frame itself stays queued for a pump
+ that is still delivering input.
+ """
+ if not inbox.full():
+ inbox.put_nowait(msg)
+ return
+ put = asyncio.create_task(inbox.put(msg))
+ try:
+ while not put.done():
+ pending = tail.pending_receive()
+ if pending is None:
+ break
+ await asyncio.wait((put, pending), return_when=asyncio.FIRST_COMPLETED)
+ if tail.saw_disconnect() and not ended.done():
+ ended.set_result(None)
+ await put
+ finally:
+ if not put.done():
+ _drop_task(put)
+
+
+async def _queue_until_close(tail: _SocketTail, inbox: asyncio.Queue, ended: asyncio.Future) -> None:
+ while (msg := await tail.next_message()) is not None:
+ await _put_frame(inbox, tail, ended, msg)
+
+
async def _read_frames(ws: WebSocket, inbox: asyncio.Queue, ended: asyncio.Future) -> None:
"""Queue inbound frames. The last item is None, or the error that ended reading
(re-raised by ``_pump_input`` so the route's handlers still see it). ``ended`` gets the same
item as soon as reading ends, before it is queued: a write waiting on the shell, or a full
inbox behind it, must not keep the pump from seeing that the socket is gone."""
end = None
+ tail = _SocketTail(ws)
try:
- while (msg := await ws.receive()).get("type") != "websocket.disconnect":
- await inbox.put(msg)
- except RuntimeError: # a superseding/drain task already closed us
- pass
- # health: allow BLE001 -- forwarded, not swallowed: _pump_input re-raises it, and a reader that died unqueued would hang the pump
- except Exception as exc:
- end = exc
- ended.set_result(end)
- await inbox.put(end)
+ try:
+ await _queue_until_close(tail, inbox, ended)
+ except RuntimeError: # a superseding/drain task already closed us
+ pass
+ # health: allow BLE001 -- forwarded, not swallowed: _pump_input re-raises it, and a reader that died unqueued would hang the pump
+ except Exception as exc:
+ end = exc
+ if not ended.done():
+ ended.set_result(end)
+ await inbox.put(end)
+ finally:
+ tail.close()
async def _write_while_open(session: PtySession, ws: WebSocket, data: bytes, fence,
diff --git a/tests/hermes_cli/test_host_terminal_viewer_ownership.py b/tests/hermes_cli/test_host_terminal_viewer_ownership.py
index 76b686b51c..4f9375a0ac 100644
--- a/tests/hermes_cli/test_host_terminal_viewer_ownership.py
+++ b/tests/hermes_cli/test_host_terminal_viewer_ownership.py
@@ -353,7 +353,11 @@ def unread_posix_pty(_monkeypatch):
@pytest.mark.asyncio
-@pytest.mark.parametrize("typed_after", [0, 64], ids=["quiet", "full-inbox"])
+@pytest.mark.parametrize(
+ "typed_after",
+ ["quiet", "full", "overflow"],
+ ids=["quiet", "full-inbox", "close-behind-full-inbox"],
+)
@pytest.mark.parametrize("terminal", [
pytest.param(unread_posix_pty, id="posix-pty", marks=pytest.mark.platforms("posix")),
pytest.param(unread_input, id="unread-input", marks=pytest.mark.platforms("any")),
@@ -365,10 +369,13 @@ async def test_closing_the_tab_during_a_waiting_paste_detaches_and_keeps_the_she
deadline. When the tab closes meanwhile, the wait is cancelled and the shell is detached, as on
any disconnect: it keeps running, retained for a reattach, and the reaper retires it once its
retention runs out. A full input queue behind the paste (keys typed while it waited) does not
- hide the close."""
+ hide the close, including when the close sits behind more frames than the queue holds."""
from hermes_cli import web_host_terminal_sessions as host_sessions
- assert typed_after in (0, host_sessions._INPUT_BATCH_FRAMES)
+ fill = host_sessions._INPUT_BATCH_FRAMES
+ # Overflow is the put that blocks: the close is not the frame that fills the queue,
+ # it is behind further keystrokes the reader has not queued yet.
+ typed = {"quiet": 0, "full": fill, "overflow": fill + 17}[typed_after]
home = tmp_path / ".hermes"
profile = home / "profiles" / "worker"
profile.mkdir(parents=True)
@@ -393,7 +400,7 @@ async def test_closing_the_tab_during_a_waiting_paste_detaches_and_keeps_the_she
try:
handler = asyncio.create_task(host_sessions.host_terminal(tab))
assert await asyncio.to_thread(waiting.wait, 5), "the paste never waited for the program"
- tab.type(*[b"k"] * typed_after)
+ tab.type(*[b"k"] * typed)
tab.close_tab()
async with asyncio.timeout(5):
await handler
@@ -410,3 +417,96 @@ async def test_closing_the_tab_during_a_waiting_paste_detaches_and_keeps_the_she
registry._sessions.clear()
registry.owners.clear()
await registry.close_all()
+
+
+class SlowAccept:
+ """A program that reads every byte, but not until ``release``: slow, not stopped.
+
+ The first write accepts its buffer (it does not report a full queue) and then waits.
+ Later writes land immediately. Keystrokes that arrived while the first write waited
+ must still be delivered, including ones that did not fit in the input queue.
+ """
+
+ def __init__(self):
+ self._closed = False
+ self.written = bytearray()
+ self.entered, self.release = threading.Event(), threading.Event()
+ self._gated = True
+
+ async def write(self, data, *, timeout=10.0, fence=None):
+ assert timeout is None, "host terminal input has no deadline"
+ accepted = await asyncio.to_thread(fence, lambda: len(data))
+ assert accepted == len(data)
+ if self._gated:
+ self._gated = False
+ self.entered.set()
+ assert await asyncio.to_thread(self.release.wait, 5), "slow reader was not released"
+ self.written.extend(data)
+ return True
+
+ def resize(self, cols, rows):
+ pass
+
+ def close(self):
+ self._closed = True
+
+ def is_alive(self):
+ return not self._closed
+
+
+@pytest.mark.asyncio
+async def test_slow_program_keeps_keystrokes_typed_past_a_full_input_queue(tmp_path, monkeypatch):
+ """A live tab's input is not dropped when the program reads slowly. Frames past the
+ inbox bound stay in order and reach the shell once it takes them; the socket is read
+ while the inbox is full, so a later close is not stuck behind that put."""
+ from hermes_cli import web_host_terminal_sessions as host_sessions
+
+ home = tmp_path / ".hermes"
+ profile = home / "profiles" / "worker"
+ profile.mkdir(parents=True)
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ owner = HostOwner(("loopback", "test"), profile, "sh", str(tmp_path))
+ registry = HostTerminalRegistry()
+ bridge = SlowAccept()
+ session = PtySession("terminal", bridge, buffer_cap=32, read_timeout=0.01)
+ registry._sessions[session.key] = session
+ registry.owners[session.key] = owner
+
+ async def admit(_ws, _registry):
+ return session, owner
+
+ monkeypatch.setattr(host_sessions, "get_host_terminals", lambda _app: registry)
+ monkeypatch.setattr(host_sessions, "_admit", admit)
+ tab = ClosingTab(b"A")
+ handler = None
+ try:
+ handler = asyncio.create_task(host_sessions.host_terminal(tab))
+ assert await asyncio.to_thread(bridge.entered.wait, 5), "the first write never started"
+ extra = [bytes([65 + (n % 26)]) for n in range(host_sessions._INPUT_BATCH_FRAMES + 17)]
+ tab.type(*extra)
+ await async_wait_until(
+ tab.inbound.empty, timeout=5,
+ what="the reader to pull keystrokes sitting behind a full input queue",
+ )
+ assert session.attached, "a full queue is not a disconnect"
+ bridge.release.set()
+ expected = b"A" + b"".join(extra)
+ await async_wait_until(
+ lambda: bytes(bridge.written) == expected, timeout=5,
+ what="every keystroke to reach the slow program",
+ )
+ tab.close_tab()
+ async with asyncio.timeout(5):
+ await handler
+ assert session.alive and not bridge._closed
+ assert registry._sessions.get(session.key) is session
+ assert not session.attached and session._ws is None
+ finally:
+ bridge.release.set()
+ if handler is not None:
+ handler.cancel()
+ await asyncio.gather(handler, return_exceptions=True)
+ registry._sessions.clear()
+ registry.owners.clear()
+ await registry.close_all()
From c2f6d93a49be2321e118a204c4030a6560e222b6 Mon Sep 17 00:00:00 2001
From: Adolanium <94890352+Adolanium@users.noreply.github.com>
Date: Mon, 26 Oct 2026 00:20:32 +0200
Subject: [PATCH 002/114] fix(webapp): detach a host terminal whose tab closes
behind a full input queue
The host terminal's socket reader stops taking frames while its 64-frame
input queue is full; that is the input backpressure. On POSIX a paste into
a program that is not reading waits with no deadline, and the pump only
cancels that wait once the reader has seen the socket end. When the tab
closed behind more than 64 queued frames, the close stayed unread behind
them: the reader blocked in inbox.put, the pump kept waiting, and the shell
stayed attached, out of reach of the 15-minute retention reaper, holding
one of the 16 host terminal slots until the program read or something else
freed it. cab0f9fee8 set out to cover this case, but its test stopped at
exactly 64 frames, which never blocks the put.
The previous version of this change kept receiving while the put waited
and parked every frame in an unbounded deque. That removed the
backpressure: a tab typing into a stuck program could make the server
buffer without limit, and frames may be up to 384 MiB each.
Instead, while the put waits, the reader sends the tab an empty binary
frame once a second. The client ignores it, and short of reading the frames
ahead of the close, a send is the only way to learn that the connection is
gone. Once the browser has dropped it, a send fails within about three
tries (the first can still reach the kernel before the reset comes back);
the reader sets `ended`, the pump cancels the waiting write, and the route
detaches the shell. The reader still takes nothing past the bound, so the
server holds no more of a tab's input than before and drops none of a live
tab's input.
---
hermes_cli/web_host_terminal_sessions.py | 150 ++++---------
.../test_host_terminal_viewer_ownership.py | 211 ++++++++++++++----
2 files changed, 208 insertions(+), 153 deletions(-)
diff --git a/hermes_cli/web_host_terminal_sessions.py b/hermes_cli/web_host_terminal_sessions.py
index d0c48b7d7a..ab487f6602 100644
--- a/hermes_cli/web_host_terminal_sessions.py
+++ b/hermes_cli/web_host_terminal_sessions.py
@@ -9,7 +9,6 @@ refused and the reaper closes it.
from __future__ import annotations
import asyncio
-from collections import deque
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass
@@ -21,8 +20,8 @@ import secrets
from fastapi import HTTPException, WebSocket, WebSocketDisconnect
from hermes_cli.pty_session import (
- WS_CLOSE_VIEWER_STALLED, WS_CLOSE_VIEWER_STALLED_REASON, PtySession, PtySessionRegistry,
- RegistryFull, _close_ws, run_reaper,
+ _VIEWER_SEND_ERRORS, _VIEWER_SEND_TIMEOUT_SECONDS, WS_CLOSE_VIEWER_STALLED,
+ WS_CLOSE_VIEWER_STALLED_REASON, PtySession, PtySessionRegistry, RegistryFull, _close_ws, run_reaper,
)
from hermes_cli.web_host_terminal import META_PREFIX
from hermes_constants import assert_named_profile_home_live, get_hermes_home
@@ -32,11 +31,11 @@ RETENTION_SECONDS = 15 * 60
_MAX_SHELLS = 16
# Inbound frames buffered while one batch is written (backpressure bound).
_INPUT_BATCH_FRAMES = 64
+# How often a reader held up by that bound checks that the tab is still there (``_queue_frame``).
+# Once the connection is gone a check fails within about three: the first can still reach the
+# kernel before the peer's reset comes back.
+_LIVENESS_PROBE_SECONDS = 1.0
TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{43}$")
-# Puts abandoned because this task is already cancelling. Awaiting them here is
-# cancelled again before they finish; holding them until the callback reads the
-# result keeps "exception was never retrieved" off the loop.
-_DROPPED_TASKS: set[asyncio.Task] = set()
class TerminalDenied(Exception):
@@ -264,120 +263,47 @@ async def _admit(ws: WebSocket, registry: HostTerminalRegistry):
return session, owner
-def _read_dropped_task(task: asyncio.Task) -> None:
- _DROPPED_TASKS.discard(task)
- if not task.cancelled():
- task.exception()
+async def _queue_frame(ws: WebSocket, inbox: asyncio.Queue, ended: asyncio.Future, msg) -> None:
+ """``inbox.put(msg)``, checking that the tab is still there while the inbox stays full.
-
-def _drop_task(task: asyncio.Task) -> None:
- """Cancel ``task`` without awaiting it. Safe while this task is itself cancelling."""
- if not task.done():
- task.cancel()
- _DROPPED_TASKS.add(task)
- task.add_done_callback(_read_dropped_task)
-
-
-class _SocketTail:
- """Socket reads that stay ahead of a full inbox.
-
- The inbox bound is backpressure for a live viewer. A put that waits for room must
- not be the only thing the reader is doing: the tab's close is the next socket
- message, possibly behind further keystrokes, and it has to surface while that
- put is blocked. Frames already pulled are kept, in order, so a program that is
- still reading takes them. They are not dropped to make the queue move.
+ A full inbox stops the reader taking frames. That backpressure bounds what a tab can queue, but
+ it also leaves the tab's close unread behind those frames, and short of reading them only a send
+ can find out that the connection is gone. So while the put waits, the tab gets an empty binary
+ frame (no output, which the client ignores) every ``_LIVENESS_PROBE_SECONDS``; attach has already
+ sent the replay, so it is never taken for one. A send that fails means the connection is gone:
+ ``ended`` is set, which cancels a write waiting on the program, and the put goes on waiting
+ unprobed.
"""
-
- def __init__(self, ws: WebSocket):
- self._ws = ws
- self._ahead: deque = deque()
- self._recv: asyncio.Task | None = asyncio.create_task(ws.receive())
-
- def close(self) -> None:
- recv, self._recv = self._recv, None
- if recv is not None:
- _drop_task(recv)
-
- def pending_receive(self) -> asyncio.Task | None:
- return self._recv
-
- async def next_message(self):
- """The next data frame, or None once the socket has disconnected."""
- if self._ahead:
- return self._ahead.popleft()
- if self._recv is None:
- return None
- msg = await self._recv
- if msg.get("type") == "websocket.disconnect":
- self._recv = None
- return None
- self._recv = asyncio.create_task(self._ws.receive())
- return msg
-
- def saw_disconnect(self) -> bool:
- """Consume a receive that has already finished. Data stays queued ahead."""
- recv = self._recv
- if recv is None or not recv.done():
- return False
- msg = recv.result()
- if msg.get("type") == "websocket.disconnect":
- self._recv = None
- return True
- self._ahead.append(msg)
- self._recv = asyncio.create_task(self._ws.receive())
- return False
-
-
-async def _put_frame(inbox: asyncio.Queue, tail: _SocketTail, ended: asyncio.Future, msg) -> None:
- """Queue ``msg``. A full inbox still leaves the socket readable.
-
- On disconnect, ``ended`` is set before this put finishes, so a write waiting on
- the program is cancelled even though the frame itself stays queued for a pump
- that is still delivering input.
- """
- if not inbox.full():
- inbox.put_nowait(msg)
- return
- put = asyncio.create_task(inbox.put(msg))
- try:
- while not put.done():
- pending = tail.pending_receive()
- if pending is None:
- break
- await asyncio.wait((put, pending), return_when=asyncio.FIRST_COMPLETED)
- if tail.saw_disconnect() and not ended.done():
- ended.set_result(None)
- await put
- finally:
- if not put.done():
- _drop_task(put)
-
-
-async def _queue_until_close(tail: _SocketTail, inbox: asyncio.Queue, ended: asyncio.Future) -> None:
- while (msg := await tail.next_message()) is not None:
- await _put_frame(inbox, tail, ended, msg)
+ while not ended.done():
+ try:
+ return await asyncio.wait_for(inbox.put(msg), _LIVENESS_PROBE_SECONDS)
+ except TimeoutError:
+ pass
+ try:
+ await asyncio.wait_for(ws.send_bytes(b""), _VIEWER_SEND_TIMEOUT_SECONDS)
+ except _VIEWER_SEND_ERRORS:
+ ended.set_result(None)
+ await inbox.put(msg)
async def _read_frames(ws: WebSocket, inbox: asyncio.Queue, ended: asyncio.Future) -> None:
"""Queue inbound frames. The last item is None, or the error that ended reading
(re-raised by ``_pump_input`` so the route's handlers still see it). ``ended`` gets the same
- item as soon as reading ends, before it is queued: a write waiting on the shell, or a full
- inbox behind it, must not keep the pump from seeing that the socket is gone."""
+ item as soon as reading ends, before it is queued, or None as soon as a full inbox finds the
+ socket gone (``_queue_frame``): a write waiting on the shell, or a full inbox behind it, must
+ not keep the pump from seeing that the socket is gone."""
end = None
- tail = _SocketTail(ws)
try:
- try:
- await _queue_until_close(tail, inbox, ended)
- except RuntimeError: # a superseding/drain task already closed us
- pass
- # health: allow BLE001 -- forwarded, not swallowed: _pump_input re-raises it, and a reader that died unqueued would hang the pump
- except Exception as exc:
- end = exc
- if not ended.done():
- ended.set_result(end)
- await inbox.put(end)
- finally:
- tail.close()
+ while (msg := await ws.receive()).get("type") != "websocket.disconnect":
+ await _queue_frame(ws, inbox, ended, msg)
+ except RuntimeError: # a superseding/drain task already closed us
+ pass
+ # health: allow BLE001 -- forwarded, not swallowed: _pump_input re-raises it, and a reader that died unqueued would hang the pump
+ except Exception as exc:
+ end = exc
+ if not ended.done():
+ ended.set_result(end)
+ await inbox.put(end)
async def _write_while_open(session: PtySession, ws: WebSocket, data: bytes, fence,
diff --git a/tests/hermes_cli/test_host_terminal_viewer_ownership.py b/tests/hermes_cli/test_host_terminal_viewer_ownership.py
index 4f9375a0ac..1f1e449635 100644
--- a/tests/hermes_cli/test_host_terminal_viewer_ownership.py
+++ b/tests/hermes_cli/test_host_terminal_viewer_ownership.py
@@ -1,15 +1,16 @@
"""Viewer takeover linearizes at the PTY sink, including off-loop writes."""
import asyncio
-from contextlib import contextmanager
+from contextlib import contextmanager, suppress
import os
from pathlib import Path
+import socket
import threading
import pytest
from hermes_cli.pty_session import PtySession, WS_CLOSE_SUPERSEDED
from hermes_cli.web_host_terminal_sessions import HostOwner, HostTerminalRegistry
-from tests.waiting import async_wait_until
+from tests.waiting import async_wait_until, wait_until
class Socket:
@@ -257,13 +258,15 @@ async def test_host_reaper_releases_dead_shell_before_drain_observes_eof(tmp_pat
class ClosingTab:
"""A browser tab on the host terminal: it sends ``frames``, then whatever ``type`` adds, and its
socket ends when ``close`` is called. Closing never cancels the route: like uvicorn, it only
- queues the disconnect for the next ``receive``."""
+ queues the disconnect for the next ``receive``. ``inbound`` holds what the server has not read
+ yet; ``sent`` is every binary frame the tab received."""
app = None
query_params: dict = {}
def __init__(self, *frames):
self.inbound = asyncio.Queue()
+ self.sent = []
self.type(*frames)
def type(self, *frames):
@@ -279,8 +282,8 @@ class ClosingTab:
async def send_text(self, _text):
pass
- async def send_bytes(self, _data):
- pass
+ async def send_bytes(self, data):
+ self.sent.append(data)
async def close(self, code=1000, reason=None):
pass
@@ -353,11 +356,7 @@ def unread_posix_pty(_monkeypatch):
@pytest.mark.asyncio
-@pytest.mark.parametrize(
- "typed_after",
- ["quiet", "full", "overflow"],
- ids=["quiet", "full-inbox", "close-behind-full-inbox"],
-)
+@pytest.mark.parametrize("typed_after", [0, 64], ids=["quiet", "full-inbox"])
@pytest.mark.parametrize("terminal", [
pytest.param(unread_posix_pty, id="posix-pty", marks=pytest.mark.platforms("posix")),
pytest.param(unread_input, id="unread-input", marks=pytest.mark.platforms("any")),
@@ -369,13 +368,10 @@ async def test_closing_the_tab_during_a_waiting_paste_detaches_and_keeps_the_she
deadline. When the tab closes meanwhile, the wait is cancelled and the shell is detached, as on
any disconnect: it keeps running, retained for a reattach, and the reaper retires it once its
retention runs out. A full input queue behind the paste (keys typed while it waited) does not
- hide the close, including when the close sits behind more frames than the queue holds."""
+ hide the close."""
from hermes_cli import web_host_terminal_sessions as host_sessions
- fill = host_sessions._INPUT_BATCH_FRAMES
- # Overflow is the put that blocks: the close is not the frame that fills the queue,
- # it is behind further keystrokes the reader has not queued yet.
- typed = {"quiet": 0, "full": fill, "overflow": fill + 17}[typed_after]
+ assert typed_after in (0, host_sessions._INPUT_BATCH_FRAMES)
home = tmp_path / ".hermes"
profile = home / "profiles" / "worker"
profile.mkdir(parents=True)
@@ -400,7 +396,7 @@ async def test_closing_the_tab_during_a_waiting_paste_detaches_and_keeps_the_she
try:
handler = asyncio.create_task(host_sessions.host_terminal(tab))
assert await asyncio.to_thread(waiting.wait, 5), "the paste never waited for the program"
- tab.type(*[b"k"] * typed)
+ tab.type(*[b"k"] * typed_after)
tab.close_tab()
async with asyncio.timeout(5):
await handler
@@ -419,13 +415,140 @@ async def test_closing_the_tab_during_a_waiting_paste_detaches_and_keeps_the_she
await registry.close_all()
-class SlowAccept:
- """A program that reads every byte, but not until ``release``: slow, not stopped.
+class BrowserTab:
+ """A browser's end of the host terminal socket: a real WebSocket client on a plain TCP socket."""
- The first write accepts its buffer (it does not report a full queue) and then waits.
- Later writes land immediately. Keystrokes that arrived while the first write waited
- must still be delivered, including ones that did not fit in the input queue.
- """
+ def __init__(self, port):
+ from websockets.client import ClientProtocol
+ from websockets.protocol import State
+ from websockets.uri import parse_uri
+
+ self.protocol = ClientProtocol(parse_uri(f"ws://127.0.0.1:{port}/terminal"))
+ self.sock = socket.create_connection(("127.0.0.1", port), timeout=10)
+ self.protocol.send_request(self.protocol.connect())
+ self._flush()
+ while self.protocol.state is State.CONNECTING:
+ self.protocol.receive_data(self.sock.recv(65536))
+ assert self.protocol.state is State.OPEN, self.protocol.handshake_exc
+
+ def _flush(self):
+ self.sock.sendall(b"".join(self.protocol.data_to_send()))
+
+ def send(self, *frames):
+ for frame in frames:
+ self.protocol.send_binary(frame)
+ self._flush()
+
+ def close(self):
+ """Go away as a closing tab does: a close frame, then the TCP connection. A browser has read
+ everything the server sent, so the connection ends with a FIN, not a reset."""
+ self.protocol.send_close(1001)
+ self._flush()
+ self.sock.setblocking(False)
+ with suppress(BlockingIOError):
+ while self.sock.recv(65536):
+ pass
+ self.sock.close()
+
+
+@contextmanager
+def served_host_terminal(monkeypatch, session, owner):
+ """``host_terminal`` for ``session`` behind real uvicorn and Starlette on a loopback port, with
+ uvicorn's default WebSocket protocol and the loopback Webapp's settings (no WebSocket pings).
+ Yields the port, the frames the route has taken from the socket, and an Event set once the
+ route returns."""
+ import uvicorn
+ from starlette.applications import Starlette
+ from starlette.routing import WebSocketRoute
+ from hermes_cli import web_host_terminal_sessions as host_sessions
+
+ registry = HostTerminalRegistry()
+ taken, finished = [], threading.Event()
+
+ async def admit(_ws, _registry):
+ return session, owner
+
+ monkeypatch.setattr(host_sessions, "get_host_terminals", lambda _app: registry)
+ monkeypatch.setattr(host_sessions, "_admit", admit)
+
+ async def route(ws):
+ await ws.accept()
+ try:
+ await host_sessions.host_terminal(ws)
+ finally:
+ finished.set()
+
+ app = Starlette(routes=[WebSocketRoute("/terminal", route)])
+
+ async def counting(scope, receive, send):
+ async def counted():
+ message = await receive()
+ if message["type"] == "websocket.receive":
+ taken.append(message)
+ return message
+
+ await app(scope, counted, send)
+
+ sock = socket.socket()
+ sock.bind(("127.0.0.1", 0))
+ server = uvicorn.Server(uvicorn.Config(
+ counting, lifespan="off", log_level="error", ws_ping_interval=None, ws_ping_timeout=None,
+ # A route that never returns (the bug) is cancelled instead of holding shutdown forever.
+ timeout_graceful_shutdown=1,
+ ))
+ thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True)
+ thread.start()
+ try:
+ wait_until(lambda: server.started or not thread.is_alive(), what="uvicorn to start")
+ assert server.started
+ yield sock.getsockname()[1], taken, finished
+ finally:
+ server.should_exit = True
+ thread.join(10)
+ sock.close()
+ asyncio.run(registry.close_all())
+ assert not thread.is_alive()
+
+
+@pytest.mark.parametrize("terminal", [
+ pytest.param(unread_posix_pty, id="posix-pty", marks=pytest.mark.platforms("posix")),
+ pytest.param(unread_input, id="unread-input", marks=pytest.mark.platforms("any")),
+])
+def test_a_tab_closed_behind_more_keys_than_the_input_queue_holds_is_detached(tmp_path, monkeypatch, terminal):
+ """Keys typed while a paste waits on a program that is not reading fill the input queue and
+ then some, so the tab's close is unread behind them when it comes, and the browser drops the
+ connection. Over a real socket the shell is still detached within a few seconds: it keeps
+ running, retained for a reattach."""
+ from hermes_cli import web_host_terminal_sessions as host_sessions
+
+ home = tmp_path / ".hermes"
+ profile = home / "profiles" / "worker"
+ profile.mkdir(parents=True)
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ owner = HostOwner(("loopback", "test"), profile, "sh", str(tmp_path))
+ # 1 MiB: more than a pseudo-terminal buffers anywhere between its master and the program.
+ paste = b"".join(b"%05d %s\n" % (n, b"x" * 57) for n in range(16 * 1024))
+ fill = host_sessions._INPUT_BATCH_FRAMES
+ with terminal(monkeypatch) as (bridge, waiting, cancelled):
+ session = PtySession("terminal", bridge, buffer_cap=32, read_timeout=0.01)
+ with served_host_terminal(monkeypatch, session, owner) as (port, taken, finished):
+ tab = BrowserTab(port)
+ tab.send(paste)
+ assert waiting.wait(5), "the paste never waited for the program"
+ tab.send(*[b"k"] * (fill + 17))
+ # The paste, a full queue, and one more frame the reader holds with no room for it.
+ wait_until(lambda: len(taken) >= fill + 2, timeout=5, what="the reader to fill the input queue")
+ tab.close()
+ assert finished.wait(10), "the shell stayed attached after its tab closed"
+ assert cancelled.is_set()
+ assert session.alive and not bridge._closed
+ assert not session.attached and session._ws is None
+
+
+class SlowAccept:
+ """A program that reads every byte, but not until ``release``: slow, not stopped. The first write
+ takes its bytes and then waits; later writes land at once."""
def __init__(self):
self._closed = False
@@ -435,12 +558,11 @@ class SlowAccept:
async def write(self, data, *, timeout=10.0, fence=None):
assert timeout is None, "host terminal input has no deadline"
- accepted = await asyncio.to_thread(fence, lambda: len(data))
- assert accepted == len(data)
+ assert await asyncio.to_thread(fence, lambda: len(data)) == len(data)
if self._gated:
self._gated = False
self.entered.set()
- assert await asyncio.to_thread(self.release.wait, 5), "slow reader was not released"
+ assert await asyncio.to_thread(self.release.wait, 10), "the slow program was not released"
self.written.extend(data)
return True
@@ -455,10 +577,12 @@ class SlowAccept:
@pytest.mark.asyncio
-async def test_slow_program_keeps_keystrokes_typed_past_a_full_input_queue(tmp_path, monkeypatch):
- """A live tab's input is not dropped when the program reads slowly. Frames past the
- inbox bound stay in order and reach the shell once it takes them; the socket is read
- while the inbox is full, so a later close is not stuck behind that put."""
+async def test_a_live_tab_typing_past_a_full_input_queue_is_held_back_and_loses_nothing(tmp_path, monkeypatch):
+ """Keys typed while a write waits on a slow program queue up to the input bound. Past it the
+ reader takes no more frames, however many the tab sends (they wait in the socket, which pushes
+ back on the browser), and checks only that the tab is still there, which leaves a live tab
+ attached and writes nothing to its terminal. Once the program reads, every key reaches it in
+ order."""
from hermes_cli import web_host_terminal_sessions as host_sessions
home = tmp_path / ".hermes"
@@ -478,24 +602,29 @@ async def test_slow_program_keeps_keystrokes_typed_past_a_full_input_queue(tmp_p
monkeypatch.setattr(host_sessions, "get_host_terminals", lambda _app: registry)
monkeypatch.setattr(host_sessions, "_admit", admit)
+ fill = host_sessions._INPUT_BATCH_FRAMES
+ typed = [bytes([65 + n % 26]) for n in range(16 * fill)]
+ # The first write holds b"A"; the reader queues ``fill`` frames and holds one more with no room.
+ unread = len(typed) - fill - 1
tab = ClosingTab(b"A")
handler = None
try:
handler = asyncio.create_task(host_sessions.host_terminal(tab))
assert await asyncio.to_thread(bridge.entered.wait, 5), "the first write never started"
- extra = [bytes([65 + (n % 26)]) for n in range(host_sessions._INPUT_BATCH_FRAMES + 17)]
- tab.type(*extra)
- await async_wait_until(
- tab.inbound.empty, timeout=5,
- what="the reader to pull keystrokes sitting behind a full input queue",
- )
- assert session.attached, "a full queue is not a disconnect"
+ tab.type(*typed)
+ await async_wait_until(lambda: tab.inbound.qsize() <= unread, timeout=5,
+ what="the reader to fill the input queue")
+ assert tab.inbound.qsize() == unread, "the reader took frames past the input queue"
+ replayed = len(tab.sent)
+ await async_wait_until(lambda: len(tab.sent) > replayed, timeout=5,
+ what="a check on the tab behind the full input queue")
+ assert tab.inbound.qsize() == unread, "the reader took frames while it waited for room"
+ assert session.attached and session._ws is tab, "a live tab was detached"
+ assert b"".join(tab.sent[replayed:]) == b"", "the check wrote to the tab's terminal"
bridge.release.set()
- expected = b"A" + b"".join(extra)
- await async_wait_until(
- lambda: bytes(bridge.written) == expected, timeout=5,
- what="every keystroke to reach the slow program",
- )
+ expected = b"A" + b"".join(typed)
+ await async_wait_until(lambda: bytes(bridge.written) == expected, timeout=5,
+ what="every keystroke to reach the slow program")
tab.close_tab()
async with asyncio.timeout(5):
await handler
From 7075e2f99b95f11f8688943f114329e0db8b7db5 Mon Sep 17 00:00:00 2001
From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com>
Date: Thu, 22 Oct 2026 12:26:31 +0300
Subject: [PATCH 003/114] fix(webapp): open large HTML previews in the browser
through srcdoc
"Open in browser" in the Webapp wrapped the page in a sandboxed iframe
whose src was a base64 data URL. Chromium refuses to navigate a frame
to a URL over 2 MiB, so any page above about 1.5 MB (default Plotly,
Bokeh and Folium exports) opened as a blank tab with no error.
Carry the page through srcdoc instead: decoded as UTF-8, as the data
URL's charset forced, and escaped for a double-quoted attribute (&
first, then ", < and >). Not a second blob URL, which would be an
app-origin page running agent HTML outside its sandbox. The frame keeps
sandbox="allow-scripts" only (opaque origin: no storage, popups or top
navigation) and no-referrer. The pinned data-URL assertion in
browser-desktop-bridge.test.ts now asserts the escaped srcdoc instead.
Refs #93508 (review W10)
(cherry picked from commit f8785ad7de00642b5ff55e5e6dafc09697e1884d)
---
.../src/lib/browser-desktop-bridge.test.ts | 27 ++++++++++++++++++-
apps/desktop/src/lib/browser-uploads.ts | 15 ++++++++---
2 files changed, 38 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/lib/browser-desktop-bridge.test.ts b/apps/desktop/src/lib/browser-desktop-bridge.test.ts
index 6bf90d445a..914ec1b911 100644
--- a/apps/desktop/src/lib/browser-desktop-bridge.test.ts
+++ b/apps/desktop/src/lib/browser-desktop-bridge.test.ts
@@ -778,6 +778,30 @@ describe('browser-hosted Desktop bridge', () => {
expect(stagedFile.type).toBe('text/plain')
})
+ it('opens a 2 MB HTML page through srcdoc, byte-exact and inert to the wrapper, never through a frame URL', async () => {
+ const win = mutableWindow()
+ seedWebappSession('served-token')
+ const blobs: Blob[] = []
+
+ vi.spyOn(URL, 'createObjectURL').mockImplementation(blob => {
+ blobs.push(blob as Blob)
+
+ return 'blob:http://127.0.0.1:9119/large'
+ })
+
+ expect(installBrowserDesktopBridge()).toBe(true)
+ // Chromium will not navigate a frame to a URL over 2 MiB: the old data: carrier left this blank.
+ const page = `
')
- expect(wrapper).toContain('data:text/html;charset=utf-8;base64,PGgxPnByZXZpZXc8L2gxPg==')
+ expect(wrapper).toContain('srcdoc="<h1>preview</h1>"')
+ expect(wrapper).not.toMatch(/\ssrc="data:/)
await win.hermesDesktop!.openPreviewInBrowser!(staged)
expect(open).toHaveBeenCalledExactlyOnceWith(staged, '_blank', 'noopener,noreferrer')
diff --git a/apps/desktop/src/lib/browser-uploads.ts b/apps/desktop/src/lib/browser-uploads.ts
index 651cc3b6e5..d42f420c72 100644
--- a/apps/desktop/src/lib/browser-uploads.ts
+++ b/apps/desktop/src/lib/browser-uploads.ts
@@ -1,5 +1,5 @@
import type { HermesApiRequest, HermesSelectPathsOptions, HermesStagedUpload } from '@/global'
-import { blobToDataUrl, bytesToBase64 } from '@/lib/base64'
+import { blobToDataUrl } from '@/lib/base64'
import { type BrowserBootstrap, browserFetch } from '@/lib/browser-transport'
const STAGED_UPLOAD_CACHE_LIMIT = 256
@@ -46,15 +46,24 @@ function rememberBounded(cache: Map, key: K, value: V): void {
}
}
+// The page reaches its opaque-origin frame through srcdoc, never a URL: Chromium refuses to
+// navigate a frame to a URL over 2 MiB, so a base64 data URL left any page above ~1.5 MB blank.
+// Decoded as the data URL's charset=utf-8 did, and escaped for a double-quoted attribute (`&`
+// first) so no byte of the page parses as markup of this same-origin wrapper.
function sandboxedHtmlBlob(bytes: Uint8Array): Blob {
- const source = `data:text/html;charset=utf-8;base64,${bytesToBase64(bytes)}`
+ const srcdoc = new TextDecoder('utf-8')
+ .decode(bytes)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>')
const wrapper = [
'',
'',
'',
'',
- ``,
+ ``,
''
].join('')
From 554695ed4bcb89812c79386224b5fca69e81646e Mon Sep 17 00:00:00 2001
From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com>
Date: Thu, 22 Oct 2026 11:55:57 +0300
Subject: [PATCH 004/114] fix(profiles): make browser Export profile explain
itself instead of doing nothing
In the browser Webapp, Export profile (profile menu and command palette)
did nothing: no dialog, no file, no message.
The export flow returns as soon as the bridge has no selectSavePath, and
the browser bridge never provides one.
It now reports this the way the flow reports any failed export: the
localized "Failed to export profile" title, with the reason and the
host-side command (hermes profile export ). This is the smaller
safe option: a browser export would have to serve the server's managed
export directory, which holds a secret-bearing archive, through the file
download route, and that path is unverified. Electron export with the
native dialog is unchanged.
Refs #93508 (review P3)
(cherry picked from commit f87d3d2fcc81fcbfffce7a6f8a12cfb0474e40bd)
---
apps/desktop/src/store/profile-share.test.ts | 26 ++++++++++++++++++++
apps/desktop/src/store/profile-share.ts | 5 ++++
2 files changed, 31 insertions(+)
diff --git a/apps/desktop/src/store/profile-share.test.ts b/apps/desktop/src/store/profile-share.test.ts
index f8c54d309a..5e014d03a5 100644
--- a/apps/desktop/src/store/profile-share.test.ts
+++ b/apps/desktop/src/store/profile-share.test.ts
@@ -110,6 +110,32 @@ describe('applyDesktopOverlay', () => {
})
})
+describe('runExportProfileFlow', () => {
+ // The browser Webapp bridge has no save dialog: Export must say so, not do nothing.
+ it('explains itself in the browser Webapp instead of silently returning', async () => {
+ const { installBrowserDesktopBridge } = await import('@/lib/browser-desktop-bridge')
+ const { clearWebappSession, seedWebappSession } = await import('@/test/webapp-session')
+ const { runExportProfileFlow } = await import('./profile-share')
+ const { $notifications } = await import('./notifications')
+ seedWebappSession('served-token')
+ vi.stubGlobal('fetch', vi.fn(async () => new Response('{}')))
+
+ try {
+ expect(installBrowserDesktopBridge()).toBe(true)
+ expect(window.hermesDesktop.selectSavePath).toBeUndefined()
+ await expect(runExportProfileFlow('glam')).resolves.toBeNull()
+ expect(exportProfileArchive).not.toHaveBeenCalled()
+ expect($notifications.get().at(-1)).toMatchObject({ kind: 'error', title: 'Failed to export profile' })
+ expect($notifications.get().at(-1)?.message).toContain('hermes profile export glam')
+ } finally {
+ clearWebappSession()
+ Reflect.deleteProperty(window, 'hermesDesktop')
+ document.documentElement.removeAttribute('data-hermes-desktop-host')
+ vi.unstubAllGlobals()
+ }
+ })
+})
+
describe('exportProfileBundle', () => {
it('stages desktop.json into the archive through extra_files', async () => {
skinPref.assign('glam', 'mono')
diff --git a/apps/desktop/src/store/profile-share.ts b/apps/desktop/src/store/profile-share.ts
index d1aa8f229a..9715515032 100644
--- a/apps/desktop/src/store/profile-share.ts
+++ b/apps/desktop/src/store/profile-share.ts
@@ -160,7 +160,12 @@ export async function runExportProfileFlow(profile?: string): Promise
Date: Sun, 25 Oct 2026 22:41:58 +0000
Subject: [PATCH 005/114] fix(webapp): preview a staged text file from this
host for an SSH profile
A long paste or a picked text file is staged in the profile's uploads
folder on the gateway host, even when the profile's workspace is an SSH
machine. Its chip previews it through /api/fs/read-text, which sent every
path of an SSH profile to the SSH machine, so the preview answered
"File not found".
read-text now picks its backend the way read-data-url and download
already do (_remote_fs_backend): only a file the upload route staged for
that profile, or a paste in its image cache, reads on this host. Every
other path, including an unnamed file in the same uploads folder, is
still the SSH machine's to answer.
Refs #144648 (WA-5)
Refs #144704
---
hermes_cli/web_routers/files.py | 3 ++-
tests/hermes_cli/test_web_server_fs.py | 26 ++++++++++++++++++++++++++
2 files changed, 28 insertions(+), 1 deletion(-)
diff --git a/hermes_cli/web_routers/files.py b/hermes_cli/web_routers/files.py
index 24ee1c50d0..7b1d9b1654 100644
--- a/hermes_cli/web_routers/files.py
+++ b/hermes_cli/web_routers/files.py
@@ -980,7 +980,8 @@ async def fs_list(path: str, request: Request, profile: Optional[str] = None):
@router.get("/api/fs/read-text")
async def fs_read_text(path: str, request: Request = None, profile: Optional[str] = None):
- backend = await asyncio.to_thread(_fs_backend, profile)
+ # A staged paste or pick previews from this host, as read-data-url reads it back to send.
+ backend = await asyncio.to_thread(_remote_fs_backend, path, profile)
if backend is not None:
try:
data, size, target = await asyncio.to_thread(
diff --git a/tests/hermes_cli/test_web_server_fs.py b/tests/hermes_cli/test_web_server_fs.py
index 5455e204fa..b2cf641a18 100644
--- a/tests/hermes_cli/test_web_server_fs.py
+++ b/tests/hermes_cli/test_web_server_fs.py
@@ -758,6 +758,32 @@ def test_a_picked_image_reads_back_from_the_host_for_an_ssh_profile(client, monk
assert workspace.asked == [path for path in not_host_owned for _route in range(2)]
+def test_a_staged_text_preview_reads_back_from_the_host_for_an_ssh_profile(client, monkeypatch, tmp_path):
+ """A long paste or a picked text file is staged in the profile's uploads folder on this host,
+ even when its workspace is an SSH machine. Its chip previews it through /api/fs/read-text, so
+ that one host-owned file is read here, as /api/fs/read-data-url reads a staged image. A file in
+ the uploads folder the upload route did not name is still the SSH machine's to answer."""
+ _profile, workspace = _ssh_profiles(tmp_path, monkeypatch)
+ monkeypatch.setattr(web_server.app.state, "ui_surface", "webapp", raising=False)
+ remote_dev = {"profile": "remote-dev"}
+
+ staged = client.post("/api/chat/file-upload", params=remote_dev,
+ files={"file": ("pasted.txt", b"pasted words", "text/plain")})
+ assert staged.status_code == 200, staged.text
+ staged_path = Path(staged.json()["path"])
+ unnamed = staged_path.parent / "pasted.txt"
+ unnamed.write_bytes(b"not staged")
+
+ preview = client.get("/api/fs/read-text", params={"path": str(staged_path), **remote_dev})
+ assert preview.status_code == 200, preview.text
+ assert preview.json()["text"] == "pasted words"
+ assert workspace.asked == []
+
+ not_host_owned = client.get("/api/fs/read-text", params={"path": str(unnamed), **remote_dev})
+ assert not_host_owned.status_code == 404, not_host_owned.text
+ assert workspace.asked == [str(unnamed)]
+
+
@pytest.mark.platforms("posix")
def test_a_link_staged_in_the_uploads_folder_is_not_read_from_the_host(client, monkeypatch, tmp_path):
"""A staged name that links to another host file resolves outside the uploads folder, so an
From 779298cd685315851b773f5f5d795d6f81d4f469 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Sun, 25 Oct 2026 22:43:26 +0000
Subject: [PATCH 006/114] fix(webapp): localize the browser Export profile
message
The salvaged browser Export profile notice built its reason in English.
Move it to profiles.exportNeedsHost in every bundled locale, keeping the
host-side command (hermes profile export ) verbatim.
Refs #144648 (WA-4)
Refs #144704
---
apps/desktop/src/i18n/ar_command_center.ts | 1 +
apps/desktop/src/i18n/de.ts | 1 +
apps/desktop/src/i18n/en.ts | 1 +
apps/desktop/src/i18n/es.ts | 1 +
apps/desktop/src/i18n/fr.ts | 1 +
apps/desktop/src/i18n/ja.ts | 1 +
apps/desktop/src/i18n/ru.ts | 1 +
apps/desktop/src/i18n/types.ts | 1 +
apps/desktop/src/i18n/zh-hant_command_center.ts | 1 +
apps/desktop/src/i18n/zh.ts | 1 +
apps/desktop/src/store/profile-share.ts | 3 +--
locales/_keys.desktop.json | 1 +
12 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/src/i18n/ar_command_center.ts b/apps/desktop/src/i18n/ar_command_center.ts
index 2ecaad7fea..7ee5750254 100644
--- a/apps/desktop/src/i18n/ar_command_center.ts
+++ b/apps/desktop/src/i18n/ar_command_center.ts
@@ -397,6 +397,7 @@ export const arCommandCenter = {
exported: 'تم تصدير الملف الشخصي',
failedImport: 'فشل استيراد الملف الشخصي',
failedExport: 'فشل تصدير الملف الشخصي',
+ exportNeedsHost: name => `لا يمكن للمتصفح حفظ أرشيف الملف الشخصي. على مضيف Hermes، شغّل: hermes profile export ${name}`,
allProfiles: 'كل الملفات الشخصية',
showAllProfiles: 'إظهار كل الملفات الشخصية',
switchToProfile: name => `التبديل إلى ${name}`,
diff --git a/apps/desktop/src/i18n/de.ts b/apps/desktop/src/i18n/de.ts
index 2349812799..aa912671ab 100644
--- a/apps/desktop/src/i18n/de.ts
+++ b/apps/desktop/src/i18n/de.ts
@@ -3204,6 +3204,7 @@ export const deOverrides = {
exported: 'Profil exportiert',
failedImport: 'Profil konnte nicht importiert werden',
failedExport: 'Profil konnte nicht exportiert werden',
+ exportNeedsHost: name => `Der Browser kann kein Profilarchiv speichern. Führe auf dem Hermes-Host aus: hermes profile export ${name}`,
allProfiles: 'Alle Profile',
showAllProfiles: 'Alle Profile anzeigen',
switchToProfile: name => `Zu ${name} wechseln`,
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 14a6b4533b..b7517c6614 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -2769,6 +2769,7 @@ export const en: Translations = {
exported: 'Profile exported',
failedImport: 'Failed to import profile',
failedExport: 'Failed to export profile',
+ exportNeedsHost: name => `The browser can't save a profile archive. On the Hermes host, run: hermes profile export ${name}`,
allProfiles: 'All profiles',
showAllProfiles: 'Show all profiles',
switchToProfile: name => `Switch to ${name}`,
diff --git a/apps/desktop/src/i18n/es.ts b/apps/desktop/src/i18n/es.ts
index d906e8fb6f..020415bbec 100644
--- a/apps/desktop/src/i18n/es.ts
+++ b/apps/desktop/src/i18n/es.ts
@@ -3193,6 +3193,7 @@ export const esOverrides = {
exported: 'Perfil exportado',
failedImport: 'No se pudo importar el perfil',
failedExport: 'No se pudo exportar el perfil',
+ exportNeedsHost: name => `El navegador no puede guardar un archivo de perfil. En el host de Hermes, ejecuta: hermes profile export ${name}`,
allProfiles: 'Todos los perfiles',
showAllProfiles: 'Mostrar todos los perfiles',
switchToProfile: name => `Cambiar a ${name}`,
diff --git a/apps/desktop/src/i18n/fr.ts b/apps/desktop/src/i18n/fr.ts
index 539bcc970b..14049bedd0 100644
--- a/apps/desktop/src/i18n/fr.ts
+++ b/apps/desktop/src/i18n/fr.ts
@@ -3209,6 +3209,7 @@ export const frOverrides = {
exported: 'Profil exporté',
failedImport: "Échec de l'importation du profil",
failedExport: "Échec de l'exportation du profil",
+ exportNeedsHost: name => `Le navigateur ne peut pas enregistrer une archive de profil. Sur l'hôte Hermes, exécutez : hermes profile export ${name}`,
allProfiles: 'Tous les profils',
showAllProfiles: 'Afficher tous les profils',
switchToProfile: name => `Basculer vers ${name}`,
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 5b4c66d295..9dd3d249ca 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -1778,6 +1778,7 @@ export const jaOverrides = {
exported: 'プロファイルをエクスポートしました',
failedImport: 'プロファイルのインポートに失敗しました',
failedExport: 'プロファイルのエクスポートに失敗しました',
+ exportNeedsHost: name => `ブラウザーではプロファイルのアーカイブを保存できません。Hermes ホストで次を実行してください: hermes profile export ${name}`,
allProfiles: 'すべてのプロファイル',
showAllProfiles: 'すべてのプロファイルを表示',
switchToProfile: name => `${name} に切り替え`,
diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts
index a85f52d51f..f8257ea18d 100644
--- a/apps/desktop/src/i18n/ru.ts
+++ b/apps/desktop/src/i18n/ru.ts
@@ -2070,6 +2070,7 @@ export const ruOverrides = {
exported: 'Профиль экспортирован',
failedImport: 'Не удалось импортировать профиль',
failedExport: 'Не удалось экспортировать профиль',
+ exportNeedsHost: name => `Браузер не может сохранить архив профиля. На хосте Hermes выполните: hermes profile export ${name}`,
allProfiles: 'Все профили',
showAllProfiles: 'Показать все профили',
switchToProfile: name => `Переключиться на ${name}`,
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 08a6ce8600..8635f7d389 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -2438,6 +2438,7 @@ catalog: CatalogTranslations
exported: string
failedImport: string
failedExport: string
+ exportNeedsHost: (name: string) => string
allProfiles: string
showAllProfiles: string
switchToProfile: (name: string) => string
diff --git a/apps/desktop/src/i18n/zh-hant_command_center.ts b/apps/desktop/src/i18n/zh-hant_command_center.ts
index ac847a9d07..483d40fc27 100644
--- a/apps/desktop/src/i18n/zh-hant_command_center.ts
+++ b/apps/desktop/src/i18n/zh-hant_command_center.ts
@@ -320,6 +320,7 @@ export const zhHantCommandCenter = {
exported: '設定檔已匯出',
failedImport: '匯入設定檔失敗',
failedExport: '匯出設定檔失敗',
+ exportNeedsHost: name => `瀏覽器無法儲存設定檔封存。請在 Hermes 主機上執行:hermes profile export ${name}`,
allProfiles: '全部設定檔',
showAllProfiles: '顯示全部設定檔',
switchToProfile: name => `切換至 ${name}`,
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 18c168f528..507d3a5e09 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -2688,6 +2688,7 @@ export const zhOverrides = {
exported: '配置档案已导出',
failedImport: '导入配置档案失败',
failedExport: '导出配置档案失败',
+ exportNeedsHost: name => `浏览器无法保存配置档案归档。请在 Hermes 主机上运行:hermes profile export ${name}`,
allProfiles: '全部配置档案',
showAllProfiles: '显示全部配置档案',
switchToProfile: name => `切换到 ${name}`,
diff --git a/apps/desktop/src/store/profile-share.ts b/apps/desktop/src/store/profile-share.ts
index 9715515032..3d2ba3f0d3 100644
--- a/apps/desktop/src/store/profile-share.ts
+++ b/apps/desktop/src/store/profile-share.ts
@@ -163,8 +163,7 @@ export async function runExportProfileFlow(profile?: string): Promise
Date: Sun, 25 Oct 2026 23:10:08 +0000
Subject: [PATCH 007/114] fix(webapp): save a staged text preview where it was
read, and read it under a locked root
read-text now reads a staged paste or pick for an SSH profile on this
host, and a successful read enables the editor. write-text still sent
every path of an SSH profile to the SSH machine, so Save could create
or overwrite a remote file and then reload the unchanged host file.
write-text now picks its backend with _remote_fs_backend, the rule
read-text uses, so a save lands where the preview reads. Every other
path of an SSH profile is still written on the SSH machine.
With HERMES_DASHBOARD_FILES_ROOT outside the profile home, read-text
still refused a staged file with 403. It now makes the same narrow
exception read-data-url already makes for a validated host-staged
file. Every other host file outside the root is still refused.
Refs #144648 (WA-5)
Refs #144704
---
hermes_cli/web_routers/files.py | 7 ++-
tests/hermes_cli/test_web_server_fs.py | 77 ++++++++++++++++++++++++++
2 files changed, 82 insertions(+), 2 deletions(-)
diff --git a/hermes_cli/web_routers/files.py b/hermes_cli/web_routers/files.py
index 7b1d9b1654..938c5eaa4d 100644
--- a/hermes_cli/web_routers/files.py
+++ b/hermes_cli/web_routers/files.py
@@ -1004,7 +1004,9 @@ async def fs_read_text(path: str, request: Request = None, profile: Optional[str
}
profile_home = _fs_profile_home(profile)
target = _agent_fs_path(path, profile_home=profile_home)
- _hosted_fs_read_guard(target, request)
+ # The same staged-file exception read-data-url makes under a locked files root.
+ if not await asyncio.to_thread(_is_host_file, str(target), profile):
+ _hosted_fs_read_guard(target, request)
target, st = _fs_regular_file(target, profile_home)
if st.st_size > _FS_TEXT_SOURCE_MAX_BYTES:
raise HTTPException(status_code=413, detail="File too large")
@@ -1033,7 +1035,8 @@ async def fs_write_text(payload: FsWriteText, profile: Optional[str] = None):
Stale-on-disk detection is the client's job (re-read before save).
"""
text = payload.content or ""
- backend = await asyncio.to_thread(_fs_backend, profile)
+ # Save where read-text read: a staged paste or pick on this host, all else on the SSH machine.
+ backend = await asyncio.to_thread(_remote_fs_backend, payload.path, profile)
if backend is not None:
try:
target, byte_size = await asyncio.to_thread(
diff --git a/tests/hermes_cli/test_web_server_fs.py b/tests/hermes_cli/test_web_server_fs.py
index b2cf641a18..d58bf54ae8 100644
--- a/tests/hermes_cli/test_web_server_fs.py
+++ b/tests/hermes_cli/test_web_server_fs.py
@@ -690,6 +690,11 @@ class _NotOnTheSshMachine:
def __init__(self):
self.asked = []
+ self.written = []
+
+ def write_text(self, path, content, *, max_bytes):
+ self.written.append((path, content))
+ return path, len(content.encode("utf-8"))
def read_bytes(self, path, *, max_bytes, read_limit=None):
self.asked.append(path)
@@ -784,6 +789,78 @@ def test_a_staged_text_preview_reads_back_from_the_host_for_an_ssh_profile(clien
assert workspace.asked == [str(unnamed)]
+def test_a_staged_text_file_saves_where_its_preview_reads_it_for_an_ssh_profile(client, monkeypatch, tmp_path):
+ """A staged text preview on this host can be edited, and the editor saves through
+ /api/fs/write-text before it reads the file again. The save goes to the same host file the
+ preview read, so the reread shows the edit; any other path is still saved on the SSH machine."""
+ _profile, workspace = _ssh_profiles(tmp_path, monkeypatch)
+ monkeypatch.setattr(web_server.app.state, "ui_surface", "webapp", raising=False)
+ remote_dev = {"profile": "remote-dev"}
+
+ staged = client.post("/api/chat/file-upload", params=remote_dev,
+ files={"file": ("pasted.txt", b"pasted words", "text/plain")})
+ assert staged.status_code == 200, staged.text
+ staged_path = Path(staged.json()["path"])
+
+ def preview():
+ response = client.get("/api/fs/read-text", params={"path": str(staged_path), **remote_dev})
+ assert response.status_code == 200, response.text
+ return response.json()["text"]
+
+ assert preview() == "pasted words"
+ saved = client.post("/api/fs/write-text", params=remote_dev,
+ json={"path": str(staged_path), "content": "edited words"})
+ assert saved.status_code == 200, saved.text
+ assert preview() == "edited words"
+ assert staged_path.read_text(encoding="utf-8") == "edited words"
+ assert (workspace.asked, workspace.written) == ([], [])
+
+ remote = client.post("/api/fs/write-text", params=remote_dev,
+ json={"path": "/srv/repos/notes.txt", "content": "remote words"})
+ assert remote.status_code == 200, remote.text
+ assert workspace.written == [("/srv/repos/notes.txt", "remote words")]
+
+
+def test_a_staged_text_preview_reads_back_under_a_locked_files_root(client, monkeypatch, tmp_path):
+ """A locked files root outside the profile home still lets a staged paste or pick preview, as
+ /api/fs/read-data-url already does, for an SSH profile and a host-local one. Every other host
+ file outside the root stays refused: one in the profile home, and one in the uploads folder the
+ upload route did not name."""
+ profile, workspace = _ssh_profiles(tmp_path, monkeypatch)
+ monkeypatch.setattr(web_server.app.state, "ui_surface", "webapp", raising=False)
+ remote_dev = {"profile": "remote-dev"}
+
+ def stage(query):
+ staged = client.post("/api/chat/file-upload", params=query,
+ files={"file": ("pasted.txt", b"pasted words", "text/plain")})
+ assert staged.status_code == 200, staged.text
+ return Path(staged.json()["path"])
+
+ staged, default_staged = stage(remote_dev), stage({})
+ unrelated = profile / "notes.txt"
+ unnamed = staged.parent / "pasted.txt"
+ for path in (unrelated, unnamed):
+ path.write_bytes(b"not staged")
+ managed = tmp_path / "managed"
+ managed.mkdir()
+ monkeypatch.setenv("HERMES_DASHBOARD_FILES_ROOT", str(managed))
+
+ def read_text(path, query):
+ return client.get("/api/fs/read-text", params={"path": str(path), **query})
+
+ over_ssh = read_text(staged, remote_dev)
+ assert (over_ssh.status_code, over_ssh.json().get("text")) == (200, "pasted words"), over_ssh.text
+
+ monkeypatch.setattr(file_routes, "_fs_backend", lambda profile=None: None)
+ for query, path in ((remote_dev, staged), ({}, default_staged)):
+ response = read_text(path, query)
+ assert (response.status_code, response.json().get("text")) == (200, "pasted words"), response.text
+ for path in (unrelated, unnamed):
+ response = read_text(path, remote_dev)
+ assert response.status_code == 403, (path, response.text)
+ assert workspace.asked == []
+
+
@pytest.mark.platforms("posix")
def test_a_link_staged_in_the_uploads_folder_is_not_read_from_the_host(client, monkeypatch, tmp_path):
"""A staged name that links to another host file resolves outside the uploads folder, so an
From 32494cdf03b58bbd61c5ac0add3a160b209e6148 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 12:33:25 -0700
Subject: [PATCH 008/114] fix(sessions): optimize-storage shows progress in
every phase and stops idling between chunks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`hermes sessions optimize-storage` printed only "Reclaiming old index…" for the whole
teardown (9+ minutes per shadow table on a 16 GB store), nothing at all during the
VACUUM, and a garbled "Rebuilding index: 100% (0/0)8,500/1,958,548)" line when a shorter
update overwrote a longer one.
It was also idle most of the time: every 500-row chunk was followed by
max(0.2 s, 4x chunk cost) of sleep. That duty cycle (#65798) let a live gateway take
the write lock between chunks, but the only driver of these steps is the foreground
CLI, which since #110054 refuses to run while any other process holds state.db
(held_store_refusal). There is no writer to yield to, so the sleeps were pure dead time
(>95% of the teardown, ~80% of the backfill).
- Backfill/teardown chunks run back to back; chunk size adapts toward ~0.25 s per
transaction (bounded WAL growth, resumable, a progress tick each), since one row
costs ~1000x more in trigram_data than in docsize.
- Teardown reports table k/n and rows drained / rows left (one COUNT per table on
the reader); VACUUM reports elapsed time every second plus size before → estimated
size after.
- The CLI draws one self-overwriting, space-padded line per phase/table (no ESC[K).
- Drop the test fixture that zeroed the removed throttle constants.
---
hermes_cli/sessions_cmd.py | 68 ++++++--
hermes_cli/subcommands/sessions.py | 4 +-
hermes_state_fts.py | 27 ++-
hermes_state_search.py | 157 +++++++++++++-----
.../test_fts_optimize_progress.py | 54 ++++++
tests/hermes_state/test_hermes_state.py | 18 --
6 files changed, 240 insertions(+), 88 deletions(-)
create mode 100644 tests/hermes_state/test_fts_optimize_progress.py
diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py
index adbf6dd77e..8f5e6b9ea4 100644
--- a/hermes_cli/sessions_cmd.py
+++ b/hermes_cli/sessions_cmd.py
@@ -980,6 +980,52 @@ def _cmd_clean_markers(db, args):
print(f"✓ Cleared {report['rows_affected']} row(s).")
+def _optimize_progress_line(info) -> str:
+ phase, pct = info.get("phase"), f"{info.get('percent', 0):3d}%"
+ counts = f"({info.get('indexed', 0):,}/{info.get('total', 0):,}"
+ if phase == "backfill":
+ return f" Rebuilding index: {pct} {counts} messages)"
+ if phase == "teardown":
+ table = str(info.get("table", "")).removeprefix("fts_v22_trash_")
+ return (f" Reclaiming old index: table {info.get('table_index', 0)}/{info.get('table_count', 0)} "
+ f"({table}) {pct} {counts} rows)")
+ elapsed = int(info.get("elapsed", 0))
+ return (f" Compacting database (VACUUM): {elapsed // 60}m {elapsed % 60:02d}s elapsed, "
+ f"{info.get('size_bytes', 0) / 2**20:,.1f} MB → ~{info.get('estimated_bytes', 0) / 2**20:,.1f} MB")
+
+
+class _OptimizeProgress:
+ """``optimize_fts_storage`` progress: one self-overwriting line per phase (and per old-index
+ table), space-padded over the previous text (``ESC[K`` leaks under prompt_toolkit)."""
+
+ def __init__(self):
+ self._key, self._last, self._width = None, None, 0
+
+ def __call__(self, info) -> None:
+ phase = info.get("phase")
+ key = (phase, info.get("table"))
+ if key != self._key:
+ # The phase or table moved on, so the previous one ran to completion.
+ self.close(completed=True)
+ self._key = key
+ if phase == "done" or (phase in ("backfill", "teardown") and not info.get("total") and not info.get("table")):
+ return
+ self._last = info
+ self._draw(_optimize_progress_line(info))
+
+ def _draw(self, line: str) -> None:
+ print(f"\r{line}{' ' * max(0, self._width - len(line))}", end="", flush=True)
+ self._width = len(line)
+
+ def close(self, completed: bool = False) -> None:
+ last = self._last
+ if completed and last and last.get("phase") in ("backfill", "teardown"):
+ self._draw(_optimize_progress_line({**last, "percent": 100, "indexed": last.get("total", 0)}))
+ if self._width:
+ print(flush=True)
+ self._last, self._width = None, 0
+
+
def _cmd_optimize_storage(db, args):
db_path = db.db_path
if not db.fts_optimize_available():
@@ -1014,27 +1060,19 @@ def _cmd_optimize_storage(db, args):
if resp not in ("y", "yes"):
print("Cancelled.")
return
- _last = {"phase": None}
- labels = {"teardown": "Reclaiming old index", "vacuum": "Compacting database (VACUUM)", "done": "Done"}
-
- def _progress(info):
- phase = info.get("phase")
- if phase == "backfill":
- print(f"\r Rebuilding index: {info.get('percent', 0):3d}% "
- f"({info.get('indexed',0):,}/{info.get('total',0):,})", end="", flush=True)
- elif phase != _last["phase"]:
- print(f"\n {labels.get(phase, phase)}…", flush=True)
- _last["phase"] = phase
+ progress = _OptimizeProgress()
print("Optimizing search-index storage…")
try:
- result = db.optimize_fts_storage(progress_cb=_progress, vacuum=do_vacuum)
+ result = db.optimize_fts_storage(progress_cb=progress, vacuum=do_vacuum)
except Exception as e:
- print(f"\nError: optimization failed: {e}\nNo data was lost. Re-run to resume.")
+ progress.close()
+ print(f"Error: optimization failed: {e}\nNo data was lost. Re-run to resume.")
return
+ progress.close()
if not result.get("ok"):
- print(f"\nCould not optimize: {result.get('reason', 'unknown')}")
+ print(f"Could not optimize: {result.get('reason', 'unknown')}")
return
- print("\n✓ Search index optimized.")
+ print("✓ Search index optimized.")
_print_size_change(db, before_mb, prefix=" ")
if result.get("vacuumed") is False:
print(" (VACUUM was skipped or failed — run `hermes sessions optimize` later to reclaim freed space.)")
diff --git a/hermes_cli/subcommands/sessions.py b/hermes_cli/subcommands/sessions.py
index a838e1127a..b4b9d02678 100644
--- a/hermes_cli/subcommands/sessions.py
+++ b/hermes_cli/subcommands/sessions.py
@@ -171,8 +171,8 @@ def build_sessions_parser(subparsers, *, cmd_sessions: Callable) -> None:
"external-content layout. On large databases this reclaims a "
"large fraction of state.db (the old layout stored duplicate "
"copies of every message and indexed tool output). Runs "
- "foreground with a progress bar, throttles so a running gateway "
- "stays responsive, and VACUUMs at the end. Safe to interrupt and "
+ "foreground with progress for every phase (refusing while a gateway "
+ "or other Hermes process holds state.db) and VACUUMs at the end. Safe to interrupt and "
"re-run — it resumes where it left off. No conversation data is "
"changed; only the search index is rebuilt.")
_flag(sessions_optimize_storage, "--no-vacuum", default=False,
diff --git a/hermes_state_fts.py b/hermes_state_fts.py
index 7193e1e3eb..26309ad4d5 100644
--- a/hermes_state_fts.py
+++ b/hermes_state_fts.py
@@ -536,13 +536,26 @@ class SessionFtsSetupMixin:
# ── Chunked FTS rebuild engine (v23 opt-in optimize) ──
# One blocking rebuild held the write lock ~16 min on a 25 GB DB, so the
- # backfill runs in small chunks (each its own short transaction, resumable from
- # fts_rebuild_progress, claimed by CAS). A greedy loop starved other writers:
- # a pause of max(MIN_PAUSE, chunk cost x DUTY_FACTOR) caps the duty cycle
- # cross-process, unlike any same-process activity stamp.
- _FTS_REBUILD_CHUNK_ROWS = 500
- _FTS_REBUILD_DUTY_FACTOR = 4.0 # sleep >= 4x chunk cost (≤20% duty)
- _FTS_REBUILD_MIN_PAUSE = 0.2 # seconds — floor between chunks
+ # backfill and teardown run in chunks: each its own bounded transaction (WAL
+ # growth stays small), resumable from its marker (claimed by CAS), and a
+ # progress tick. They run back to back with no pause: the only driver is the
+ # foreground `hermes sessions optimize-storage`, which refuses while any other
+ # process holds state.db (held_store_refusal, #110054), so there is no writer
+ # to yield to; a fixed 0.2 s pause after each few-ms chunk was >95% of the
+ # wall time. Chunk size adapts toward TARGET seconds, since rows cost ~1000x
+ # more in trigram_data than in docsize.
+ _FTS_REBUILD_CHUNK_ROWS = 500 # first chunk; later ones adapt
+ _FTS_REBUILD_CHUNK_MIN_ROWS = 50
+ _FTS_REBUILD_CHUNK_MAX_ROWS = 1_000_000
+ _FTS_REBUILD_CHUNK_TARGET_S = 0.25
+ _fts_chunk_rows = _FTS_REBUILD_CHUNK_ROWS # rows per step; optimize's _drive adapts it per instance
+
+ def _next_chunk_rows(self, rows: int, cost_s: float) -> int:
+ """Rows for the next chunk so it costs about ``_FTS_REBUILD_CHUNK_TARGET_S``; at most
+ doubles or halves per step so one noisy chunk (a cold page, a checkpoint) can't swing it."""
+ scale = self._FTS_REBUILD_CHUNK_TARGET_S / max(cost_s, 1e-4)
+ rows = int(rows * min(2.0, max(0.5, scale)))
+ return min(self._FTS_REBUILD_CHUNK_MAX_ROWS, max(self._FTS_REBUILD_CHUNK_MIN_ROWS, rows))
# Demoted v22 FTS shadow tables awaiting teardown: DROP of a multi-GB vtable
# blocks for minutes, so the v23 migration renames the orphaned shadow tables
diff --git a/hermes_state_search.py b/hermes_state_search.py
index a7993f49d1..e73875e4cd 100644
--- a/hermes_state_search.py
+++ b/hermes_state_search.py
@@ -8,6 +8,7 @@ import contextlib
import logging
import re
import sqlite3
+import threading
import time
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple
@@ -316,9 +317,9 @@ class SessionSearchMixin:
self._execute_write(_do)
def fts_rebuild_step(self) -> bool:
- """Backfill one chunk of the deferred FTS rebuild; True while work remains. Chunks are
- claimed atomically inside the write transaction, so concurrent processes interleave
- instead of duplicating rows."""
+ """Backfill one chunk (``_fts_chunk_rows`` ids) of the deferred FTS rebuild; True while work
+ remains. Chunks are claimed atomically inside the write transaction, so concurrent processes
+ interleave instead of duplicating rows."""
if not self._fts_enabled:
return False
inserts = [self._CHUNK_INSERT_SQL.format(table="messages_fts", extra="")]
@@ -343,7 +344,7 @@ class SessionSearchMixin:
if high_water_raw is None:
return False
high_water = int(high_water_raw)
- chunk = self._FTS_REBUILD_CHUNK_ROWS
+ chunk = self._fts_chunk_rows
def _do(conn):
# Re-reading progress inside the BEGIN IMMEDIATE held by _execute_write IS
@@ -376,17 +377,18 @@ class SessionSearchMixin:
return False
return bool(more)
- def _fts_teardown_trash_step(self) -> bool:
- """Tear down one chunk of a demoted v22 FTS shadow table (a PLAIN table now); True while
- work remains. INTEGER single-column-key tables drain with a high-water marker so
- each chunk's scan is bounded (restarting the scan was O(n²)); compound-key tables
- keep the chunked ``LIMIT`` delete — they are small by construction.
+ def _fts_teardown_trash_step(self, progress: Optional[dict] = None) -> bool:
+ """Tear down one chunk (``_fts_chunk_rows`` rows) of a demoted v22 FTS shadow table (a PLAIN
+ table now); True while work remains. ``progress``, when given, is kept current as {"table", "table_index",
+ "table_count", "drained", "total"} for the table being drained.
- Single-column-key trash tables (the common shape — FTS shadow tables carry a rowid/integer PK) are
- drained with a high-water marker mirroring :meth:`fts_rebuild_step`: each chunk deletes only rows
- after the previously-drained key, so the per-chunk scan is bounded instead of re-scanning from the
- start of the table every chunk (O(n²) total on large trash tables, #79324).
+ Single-column INTEGER-key trash tables (the common shape — FTS shadow tables carry a rowid/integer
+ PK) are drained with a high-water marker mirroring :meth:`fts_rebuild_step`: each chunk deletes only
+ rows after the previously-drained key, so the per-chunk scan is bounded instead of re-scanning from
+ the start of the table every chunk (O(n²) total on large trash tables, #79324). Compound-key tables
+ keep the chunked ``LIMIT`` delete — they are small by construction.
"""
+ limit = self._fts_chunk_rows
with self._read_ctx() as conn:
trash = [r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE ? ESCAPE '\\'",
@@ -407,24 +409,22 @@ class SessionSearchMixin:
row = _meta_row(conn, marker_key)
high_water = int(row[0]) if row is not None else 0
# Claim the LAST row of the LIMIT window so a full chunk goes per step.
- upper_rows = conn.execute(
- f"SELECT {key} FROM {tbl} WHERE {key} > ? ORDER BY {key} LIMIT {self._FTS_REBUILD_CHUNK_ROWS}",
- (high_water,),
- ).fetchall()
- if not upper_rows:
- return _drop(conn, marker_key)
- upper = upper_rows[-1][0]
+ upper_row = conn.execute(
+ f"SELECT {key} FROM {tbl} WHERE {key} > ? ORDER BY {key} LIMIT 1 OFFSET ?",
+ (high_water, limit - 1),
+ ).fetchone() or conn.execute(f"SELECT MAX({key}) FROM {tbl} WHERE {key} > ?", (high_water,)).fetchone()
+ if upper_row is None or upper_row[0] is None:
+ return _drop(conn, marker_key), 0
+ upper = upper_row[0]
cur = conn.execute(f"DELETE FROM {tbl} WHERE {key} > ? AND {key} <= ?", (high_water, upper))
if cur.rowcount > 0:
self.set_meta(marker_key, str(upper), cursor=conn)
- return True
+ return True, cur.rowcount
# Compound-key or rowid trash table: legacy chunked delete. These shadow tables are small, so
# the quadratic re-scan is not a concern (#79324 keeps the high-water path for the big
# single-key tables).
- cur = conn.execute(
- f"DELETE FROM {tbl} WHERE ({key}) IN (SELECT {key} FROM {tbl} LIMIT {self._FTS_REBUILD_CHUNK_ROWS})"
- )
- return _drop(conn) if cur.rowcount == 0 else True # True: more trash tables / chunks may remain
+ cur = conn.execute(f"DELETE FROM {tbl} WHERE ({key}) IN (SELECT {key} FROM {tbl} LIMIT {int(limit)})")
+ return (_drop(conn) if cur.rowcount == 0 else True), cur.rowcount
def _drop(conn, marker_key: Optional[str] = None) -> bool:
"""Drained — the DROP is cheap now. True: re-check for more trash."""
@@ -435,10 +435,33 @@ class SessionSearchMixin:
return True
try:
- return bool(self._execute_write(_do))
+ more, deleted = self._execute_write(_do)
except sqlite3.OperationalError as exc:
logger.debug("FTS trash teardown chunk failed (will retry): %s", exc)
return True
+ if progress is not None:
+ # Counted after the commit: _execute_write may replay _do on contention.
+ progress["drained"] = progress.get("drained", 0) + deleted
+ return bool(more)
+
+ def _fts_teardown_track(self, progress: dict) -> None:
+ """Point *progress* at the trash table the next teardown chunk drains: its position in the run
+ and the rows left in it. One COUNT per table, on the reader (no writer waits on it) and outside
+ the timed chunk (so it never skews the chunk sizing)."""
+ with self._read_ctx() as conn:
+ trash = [r[0] for r in conn.execute(
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE ? ESCAPE '\\'",
+ (self._FTS_TRASH_PREFIX.replace("_", "\\_") + "%",),
+ ).fetchall()]
+ if not trash or progress.get("table") == trash[0]:
+ return
+ tbl = trash[0]
+ # Only INTEGER-pk tables (rowid aliases) carry a resume marker; count what is left past it.
+ marker = _meta_row(conn, f"fts_teardown_{tbl}_progress")
+ total = conn.execute(f"SELECT COUNT(*) FROM {tbl} WHERE rowid > ?", (int(marker[0]),)).fetchone()[0] \
+ if marker is not None else conn.execute(f"SELECT COUNT(*) FROM {tbl}").fetchone()[0]
+ count = progress.setdefault("table_count", len(trash))
+ progress.update(table=tbl, table_index=max(1, count - len(trash) + 1), drained=0, total=int(total))
def _fts_cjk_reset_if_stale(self) -> None:
"""From-scratch rebuild of a stale cjk index (triggers were dropped, gap extent unknown):
@@ -608,9 +631,44 @@ class SessionSearchMixin:
raise sqlite3.OperationalError(failure_message)
self._conn.commit()
- def _optimize_vacuum(self) -> bool:
+ def _optimize_progress_info(self, phase: str, track: dict) -> dict[str, Any]:
+ """``progress_cb`` payload: {"phase", "percent", "indexed", "total"} for every phase, plus
+ {"table", "table_index", "table_count"} while tearing down and {"elapsed", "size_bytes",
+ "estimated_bytes"} for the VACUUM (one statement: elapsed time, not a percent)."""
+ if phase == "teardown" and track.get("table"):
+ done, total = track["drained"], track["total"]
+ return {"phase": phase, "percent": min(100, 100 * done // total) if total else 100, "indexed": done,
+ "total": total, **{k: track[k] for k in ("table", "table_index", "table_count")}}
+ if phase == "vacuum" and "vacuum_started" in track:
+ return {"phase": phase, "percent": 0, "indexed": 0, "total": 0,
+ "elapsed": time.monotonic() - track["vacuum_started"],
+ **{k: track[k] for k in ("size_bytes", "estimated_bytes")}}
+ st = self.fts_rebuild_status() or self.fts_cjk_rebuild_status()
+ return {"phase": phase, "percent": st["percent"] if st else 100,
+ "indexed": st["indexed"] if st else 0, "total": st["total"] if st else 0}
+
+ def _optimize_vacuum(self, track: dict, tick: Optional[Callable[[], None]] = None) -> bool:
"""Phase 3: reclaim freed pages to the OS. False when VACUUM failed (usually no free disk
- for its temp copy; a later VACUUM reclaims)."""
+ for its temp copy; a later VACUUM reclaims). VACUUM is one statement that runs for minutes
+ on a big store, so *tick* is called every second from a side thread to show it is alive;
+ *track* gets the size before and the estimated size after (live pages only)."""
+ with self._lock:
+ page_size, pages, free = (self._conn.execute(f"PRAGMA {p}").fetchone()[0]
+ for p in ("page_size", "page_count", "freelist_count"))
+ track.update(vacuum_started=time.monotonic(), size_bytes=pages * page_size,
+ estimated_bytes=(pages - free) * page_size)
+ stop = threading.Event()
+
+ def _tick() -> None:
+ tick()
+ while not stop.wait(1.0):
+ tick()
+
+ from agent.memory_provider import spawn_context_thread
+
+ ticker = spawn_context_thread(_tick, name="optimize-vacuum-ticker")
+ if tick is not None:
+ ticker.start()
try:
with self._lock:
self._conn.execute("VACUUM")
@@ -618,6 +676,10 @@ class SessionSearchMixin:
except sqlite3.OperationalError as exc:
logger.warning("VACUUM after FTS optimize failed: %s", exc)
vacuum_ok = False
+ finally:
+ stop.set()
+ if ticker.is_alive():
+ ticker.join()
# Best-effort WAL fold-back, REFUSED (SQLITE_BUSY) while another connection holds a
# read-mark (callers size via logical_size_bytes, not stat()). PASSIVE, never TRUNCATE:
# a TRUNCATE reset from a transient CLI would race a live writer.
@@ -660,8 +722,9 @@ class SessionSearchMixin:
) -> dict[str, Any]:
"""Repair an older FTS layout into the current v23 shape, foreground and to completion:
legacy-v22 inline -> external-content, or a v23 ``messages_fts_trigram`` that still stores
- ``tool_calls``. Re-running resumes. ``progress_cb`` receives {"phase", "percent",
- "indexed", "total"}. A missing trigram tokenizer is not fatal (CJK falls back to LIKE)."""
+ ``tool_calls``. Re-running resumes. ``progress_cb`` receives :meth:`_optimize_progress_info`
+ after every chunk and every second of the VACUUM. A missing trigram tokenizer is not fatal
+ (CJK falls back to LIKE)."""
if not self._fts_enabled:
return {"ok": False, "reason": "fts5_unavailable"}
if self.read_only:
@@ -685,32 +748,35 @@ class SessionSearchMixin:
if self._fts_cjk_loaded:
self._ensure_cjk_schema_committed()
- def _emit(phase: str) -> None:
- if progress_cb is None:
- return
- st = self.fts_rebuild_status() or self.fts_cjk_rebuild_status()
- progress_cb({"phase": phase, "percent": st["percent"] if st else 100,
- "indexed": st["indexed"] if st else 0, "total": st["total"] if st else 0})
+ track: dict[str, Any] = {} # teardown table/rows and VACUUM sizes, for progress_cb
- def _drive(phase: str, step) -> None:
- """Run *step* to completion; the inter-chunk sleep is the single place the duty
- cycle is enforced — back-to-back BEGIN IMMEDIATE chunks starve a live
- gateway/CLI out of its lock retries."""
+ def _emit(phase: str) -> None:
+ if progress_cb is not None:
+ progress_cb(self._optimize_progress_info(phase, track))
+
+ def _drive(phase: str, step, before=None, **step_kwargs) -> None:
+ """Run *step* to completion, back to back (no other writer: see the chunk engine
+ note in hermes_state_fts). Chunk size adapts to its measured cost."""
+ self._fts_chunk_rows = self._FTS_REBUILD_CHUNK_ROWS
while True:
+ if before is not None:
+ before()
_t0 = time.monotonic()
- if not step():
+ if not step(**step_kwargs):
break
+ cost = time.monotonic() - _t0
_emit(phase)
- time.sleep(max(self._FTS_REBUILD_MIN_PAUSE, (time.monotonic() - _t0) * self._FTS_REBUILD_DUTY_FACTOR))
+ self._fts_chunk_rows = self._next_chunk_rows(self._fts_chunk_rows, cost)
# Phase 1: base backfill; 1b: CJK-bigram backfill (own marker pair).
_emit("backfill")
_drive("backfill", self.fts_rebuild_step)
- _emit("backfill")
_drive("backfill", self.fts_cjk_rebuild_step)
# Phase 2: tear down the demoted legacy shadow tables in chunks.
+ self._fts_teardown_track(track)
_emit("teardown")
- _drive("teardown", self._fts_teardown_trash_step)
+ _drive("teardown", self._fts_teardown_trash_step, before=lambda: self._fts_teardown_track(track),
+ progress=track)
with self._read_ctx() as conn:
still_pending = _meta_row(conn, "fts_rebuild_high_water") is not None
still_trash = self._has_fts_trash(conn)
@@ -723,8 +789,7 @@ class SessionSearchMixin:
vacuum_ok = None
if vacuum:
- _emit("vacuum")
- vacuum_ok = self._optimize_vacuum()
+ vacuum_ok = self._optimize_vacuum(track, None if progress_cb is None else lambda: _emit("vacuum"))
refusal = self._execute_write(self._optimize_settle)
if refusal is not None:
# A concurrent process changed state since the pre-vacuum check; a re-run can still settle.
diff --git a/tests/hermes_state/test_fts_optimize_progress.py b/tests/hermes_state/test_fts_optimize_progress.py
new file mode 100644
index 0000000000..a66a79cfcc
--- /dev/null
+++ b/tests/hermes_state/test_fts_optimize_progress.py
@@ -0,0 +1,54 @@
+"""`hermes sessions optimize-storage` runs in the foreground behind the held-store refusal, so it must
+report real progress in every phase and never idle between chunks."""
+
+import sqlite3
+import time
+
+import pytest
+
+from hermes_state import SessionDB
+from hermes_state_common import LEGACY_FTS_SQL, SCHEMA_SQL
+
+
+def _legacy_inline_store(db_path, n=1200):
+ raw = sqlite3.connect(str(db_path))
+ try:
+ raw.executescript(SCHEMA_SQL + LEGACY_FTS_SQL)
+ raw.execute("INSERT INTO sessions (id, source, started_at) VALUES ('s1', 'cli', ?)", (time.time(),))
+ raw.executemany(
+ "INSERT INTO messages (session_id, timestamp, role, content) VALUES ('s1', ?, 'user', ?)",
+ [(time.time(), f"legacy payload {i} zebra quokka") for i in range(n)],
+ )
+ raw.commit()
+ finally:
+ raw.close()
+
+
+@pytest.fixture
+def legacy_db(tmp_path):
+ _legacy_inline_store(tmp_path / "state.db")
+ db = SessionDB(db_path=tmp_path / "state.db")
+ assert db.fts_optimize_available()
+ yield db
+ db.close()
+
+
+def test_teardown_reports_which_table_and_how_many_rows(legacy_db):
+ events = []
+ result = legacy_db.optimize_fts_storage(progress_cb=events.append, vacuum=False)
+ assert result["ok"] is True
+ teardown = [e for e in events if e["phase"] == "teardown"]
+ # The docsize shadow table holds one row per message: its drain must be visible as rows, not a bare label.
+ docsize = [e for e in teardown if e.get("table", "").endswith("messages_fts_docsize")]
+ assert docsize and docsize[0]["total"] == 1200
+ assert docsize[-1]["indexed"] == 1200 and docsize[-1]["percent"] == 100
+ count = teardown[0]["table_count"]
+ assert count >= 2 and {e["table_index"] for e in teardown} <= set(range(1, count + 1))
+ assert legacy_db.search_messages("quokka")
+
+
+def test_foreground_optimize_never_sleeps_between_chunks(legacy_db, monkeypatch):
+ slept = []
+ monkeypatch.setattr(time, "sleep", slept.append)
+ assert legacy_db.optimize_fts_storage(vacuum=False)["ok"] is True
+ assert slept == []
diff --git a/tests/hermes_state/test_hermes_state.py b/tests/hermes_state/test_hermes_state.py
index 3328aaef62..7513a7a64c 100644
--- a/tests/hermes_state/test_hermes_state.py
+++ b/tests/hermes_state/test_hermes_state.py
@@ -98,24 +98,6 @@ def db(tmp_path):
session_db.close()
-@pytest.fixture(autouse=True)
-def _no_fts_rebuild_throttle(monkeypatch):
- """Zero the FTS-rebuild inter-chunk throttle for every test in this file.
-
- ``optimize_fts_storage`` sleeps ``max(_FTS_REBUILD_MIN_PAUSE,
- chunk_cost * _FTS_REBUILD_DUTY_FACTOR)`` between chunks so a LIVE
- gateway/CLI sharing the DB isn't starved of the write lock. Tests run
- against a private tmp-path DB with no concurrent process — the sleep
- protects nobody and was pure dead time (measured: 4.1s of a 4.6s
- migration test was time.sleep; ~20s across the file, whose total was
- ~52s). The duty-cycle POLICY (sleep >= 4x chunk cost) stays covered by
- the production constants themselves; no test asserts on wall-clock
- pacing.
- """
- monkeypatch.setattr(SessionDB, "_FTS_REBUILD_MIN_PAUSE", 0.0)
- monkeypatch.setattr(SessionDB, "_FTS_REBUILD_DUTY_FACTOR", 0.0)
-
-
# =========================================================================
# Connection lifecycle
# =========================================================================
From ead7bedca6dd918241b7294949a6e21029a65a65 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 13:51:24 -0700
Subject: [PATCH 009/114] fix(sessions): optimize-storage sizes chunks by rows,
stops on a dead chunk, paces under --force
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review round on #144630:
- CI: the VACUUM size probe read PRAGMA page_count/freelist_count under the
writer lock (TestNoPureReadersUnderWriterLock); it now reads on the pooled
reader, only when a progress callback exists.
- Progress state is typed: _TeardownProgress (owned by the teardown step, which
now picks the table, counts it on the reader and deletes in one place) and
_VacuumProgress. _drive's before/**step_kwargs hooks and the duplicate trash
lookup are gone.
- Backfill windows are the next N messages (LIMIT 1 OFFSET N-1 bounded by the
high water), not N ids: an id gap no longer ramps the window to 1M ids (gap
store: worst chunk 6.6 s -> 0.39 s). Max rows 1M -> 100k (dense trigram_data
teardown chunk 0.22 s at the cap). Each trash table restarts at 500 rows.
- Step engines no longer swallow OperationalError into "more work"; the drive
loop retries a failing chunk 3 times (lock failures already waited out
_execute_write's patience) and then raises, so SQLITE_FULL ends the run with
"No data was lost. Re-run to resume." instead of spinning.
- --force past a live holder (start-up scan finds one) passes
yield_to_writers: chunks near 50 ms plus max(0.2 s, 4x cost) pause, so the
holder's writes are not starved (writer p50 0.012 s, max 0.17 s). The
foreground default stays pause-free.
- VACUUM ticker is built only with a callback, via copy_context().run (HX012)
instead of importing agent.memory_provider into the storage layer; each
table's 100% line is drawn once; Ctrl-C prints "Interrupted — no data was
lost; re-run to resume." (exit 130) instead of a traceback.
---
hermes_cli/sessions_cmd.py | 29 +-
hermes_cli/subcommands/sessions.py | 2 +-
hermes_state_fts.py | 24 +-
hermes_state_search.py | 247 ++++++++++--------
.../test_fts_optimize_progress.py | 89 ++++++-
5 files changed, 258 insertions(+), 133 deletions(-)
diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py
index 8f5e6b9ea4..81fc9b9efe 100644
--- a/hermes_cli/sessions_cmd.py
+++ b/hermes_cli/sessions_cmd.py
@@ -999,7 +999,7 @@ class _OptimizeProgress:
table), space-padded over the previous text (``ESC[K`` leaks under prompt_toolkit)."""
def __init__(self):
- self._key, self._last, self._width = None, None, 0
+ self._key, self._last, self._line = None, None, ""
def __call__(self, info) -> None:
phase = info.get("phase")
@@ -1014,16 +1014,24 @@ class _OptimizeProgress:
self._draw(_optimize_progress_line(info))
def _draw(self, line: str) -> None:
- print(f"\r{line}{' ' * max(0, self._width - len(line))}", end="", flush=True)
- self._width = len(line)
+ if line == self._line: # a table's final chunk, its DROP and the switch all report 100%
+ return
+ print(f"\r{line}{' ' * max(0, len(self._line) - len(line))}", end="", flush=True)
+ self._line = line
def close(self, completed: bool = False) -> None:
last = self._last
if completed and last and last.get("phase") in ("backfill", "teardown"):
self._draw(_optimize_progress_line({**last, "percent": 100, "indexed": last.get("total", 0)}))
- if self._width:
+ if self._line:
print(flush=True)
- self._last, self._width = None, 0
+ self._last, self._line = None, ""
+
+
+def _store_has_foreign_holder(db_path) -> bool:
+ from hermes_state_holders import held_store_refusal
+
+ return held_store_refusal(db_path, command="optimize-storage") is not None
def _cmd_optimize_storage(db, args):
@@ -1060,10 +1068,19 @@ def _cmd_optimize_storage(db, args):
if resp not in ("y", "yes"):
print("Cancelled.")
return
+ # The holder check was skipped under --force: if someone really holds the store, keep the old
+ # duty-cycle pause so their writes are not starved (slower, but they stay responsive).
+ yield_to_writers = bool(getattr(args, "force", False)) and _store_has_foreign_holder(db_path)
+ if yield_to_writers:
+ print(" Another process is using the database (--force): pacing the rebuild so it stays responsive.")
progress = _OptimizeProgress()
print("Optimizing search-index storage…")
try:
- result = db.optimize_fts_storage(progress_cb=progress, vacuum=do_vacuum)
+ result = db.optimize_fts_storage(progress_cb=progress, vacuum=do_vacuum, yield_to_writers=yield_to_writers)
+ except KeyboardInterrupt:
+ progress.close()
+ print("Interrupted — no data was lost; re-run to resume.")
+ return 130
except Exception as e:
progress.close()
print(f"Error: optimization failed: {e}\nNo data was lost. Re-run to resume.")
diff --git a/hermes_cli/subcommands/sessions.py b/hermes_cli/subcommands/sessions.py
index b4b9d02678..4dc9388e1d 100644
--- a/hermes_cli/subcommands/sessions.py
+++ b/hermes_cli/subcommands/sessions.py
@@ -180,7 +180,7 @@ def build_sessions_parser(subparsers, *, cmd_sessions: Callable) -> None:
_flag(sessions_optimize_storage, "--yes", "-y", default=False,
help="Skip the disk-space confirmation prompt")
_flag(sessions_optimize_storage, "--force",
- help="Run even while another Hermes process (gateway, Desktop, dashboard, cron) holds state.db — rewriting the store under a live writer can leave every agent refusing turns until all writers are stopped")
+ help="Run even while another Hermes process (gateway, Desktop, dashboard, cron) holds state.db — rewriting the store under a live writer can leave every agent refusing turns until all writers are stopped; the rebuild then paces itself so that process stays responsive (much slower)")
sessions_repair = sessions_subparsers.add_parser(
"repair", help="Repair a malformed state.db schema so hidden sessions reappear",
diff --git a/hermes_state_fts.py b/hermes_state_fts.py
index 26309ad4d5..00b51a8669 100644
--- a/hermes_state_fts.py
+++ b/hermes_state_fts.py
@@ -542,18 +542,24 @@ class SessionFtsSetupMixin:
# foreground `hermes sessions optimize-storage`, which refuses while any other
# process holds state.db (held_store_refusal, #110054), so there is no writer
# to yield to; a fixed 0.2 s pause after each few-ms chunk was >95% of the
- # wall time. Chunk size adapts toward TARGET seconds, since rows cost ~1000x
- # more in trigram_data than in docsize.
- _FTS_REBUILD_CHUNK_ROWS = 500 # first chunk; later ones adapt
+ # wall time. Chunk size (in ROWS, never an id span: a pruned store has id gaps)
+ # adapts toward TARGET seconds, since rows cost ~1000x more in trigram_data
+ # than in docsize. `--force` past a live holder (yield_to_writers) keeps the
+ # duty cycle instead: chunks near PACED_TARGET, a pause of max(MIN_PAUSE,
+ # cost x DUTY_FACTOR) after each, so that holder's writes are not starved.
+ _FTS_REBUILD_CHUNK_ROWS = 500 # first chunk of every phase and trash table; later ones adapt
_FTS_REBUILD_CHUNK_MIN_ROWS = 50
- _FTS_REBUILD_CHUNK_MAX_ROWS = 1_000_000
+ _FTS_REBUILD_CHUNK_MAX_ROWS = 100_000
_FTS_REBUILD_CHUNK_TARGET_S = 0.25
- _fts_chunk_rows = _FTS_REBUILD_CHUNK_ROWS # rows per step; optimize's _drive adapts it per instance
+ _FTS_REBUILD_PACED_TARGET_S = 0.05
+ _FTS_REBUILD_DUTY_FACTOR = 4.0 # paced: sleep >= 4x chunk cost (≤20% duty)
+ _FTS_REBUILD_MIN_PAUSE = 0.2 # paced: seconds — floor between chunks
+ _fts_chunk_rows = _FTS_REBUILD_CHUNK_ROWS # rows per step; optimize's drive loop adapts it per instance
- def _next_chunk_rows(self, rows: int, cost_s: float) -> int:
- """Rows for the next chunk so it costs about ``_FTS_REBUILD_CHUNK_TARGET_S``; at most
- doubles or halves per step so one noisy chunk (a cold page, a checkpoint) can't swing it."""
- scale = self._FTS_REBUILD_CHUNK_TARGET_S / max(cost_s, 1e-4)
+ def _next_chunk_rows(self, rows: int, cost_s: float, target_s: float) -> int:
+ """Rows for the next chunk so it costs about *target_s*; at most doubles or halves per
+ step so one noisy chunk (a cold page, a checkpoint) can't swing it."""
+ scale = target_s / max(cost_s, 1e-4)
rows = int(rows * min(2.0, max(0.5, scale)))
return min(self._FTS_REBUILD_CHUNK_MAX_ROWS, max(self._FTS_REBUILD_CHUNK_MIN_ROWS, rows))
diff --git a/hermes_state_search.py b/hermes_state_search.py
index e73875e4cd..2493d38a2a 100644
--- a/hermes_state_search.py
+++ b/hermes_state_search.py
@@ -5,11 +5,13 @@ Must never import hermes_state (cycle); shared constants live in hermes_state_co
"""
import contextlib
+import contextvars
import logging
import re
import sqlite3
import threading
import time
+from dataclasses import dataclass, field
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple
from agent.skill_commands import describe_skill_invocation
@@ -26,6 +28,40 @@ from hermes_state_fts import fts_layout_current, fts_layout_drift
logger = logging.getLogger("hermes_state")
+@dataclass
+class _TeardownProgress:
+ """Which demoted shadow table optimize-storage is draining and how far (``progress_cb``
+ payload). Owned and kept current by :meth:`SessionSearchMixin._fts_teardown_trash_step`."""
+
+ table: str = ""
+ table_index: int = 0
+ table_count: int = 0
+ total: int = 0
+ drained: int = 0
+
+ def info(self) -> dict[str, Any]:
+ done, total = self.drained, self.total
+ return {"phase": "teardown", "percent": min(100, 100 * done // total) if total else 100,
+ "indexed": done, "total": total, "table": self.table,
+ "table_index": self.table_index, "table_count": self.table_count}
+
+
+@dataclass
+class _VacuumProgress:
+ """VACUUM is one statement: liveness is elapsed time against the size before and the estimated
+ size after (live pages only), never a percent."""
+
+ size_bytes: int
+ estimated_bytes: int
+ started: float = field(default_factory=time.monotonic)
+
+ def info(self) -> dict[str, Any]:
+ return {"phase": "vacuum", "percent": 0, "indexed": 0, "total": 0,
+ "elapsed": time.monotonic() - self.started, "size_bytes": self.size_bytes,
+ "estimated_bytes": self.estimated_bytes}
+
+
+
def _search_slow_ms() -> float:
"""``sessions.search_slow_ms`` for the served profile (default 1000; 0 logs every call)."""
value = routed_sessions_setting("search_slow_ms", "HERMES_SEARCH_SLOW_MS")
@@ -317,27 +353,25 @@ class SessionSearchMixin:
self._execute_write(_do)
def fts_rebuild_step(self) -> bool:
- """Backfill one chunk (``_fts_chunk_rows`` ids) of the deferred FTS rebuild; True while work
- remains. Chunks are claimed atomically inside the write transaction, so concurrent processes
- interleave instead of duplicating rows."""
+ """Backfill one chunk (``_fts_chunk_rows`` messages) of the deferred FTS rebuild; True while
+ work remains. Chunks are claimed atomically inside the write transaction, so concurrent
+ processes interleave instead of duplicating rows. A failed chunk raises (nothing committed;
+ the caller decides whether to retry)."""
if not self._fts_enabled:
return False
inserts = [self._CHUNK_INSERT_SQL.format(table="messages_fts", extra="")]
if self._trigram_available:
inserts.append(self._TRIGRAM_CHUNK_INSERT_SQL)
- return self._rebuild_step("fts_rebuild", inserts, fail_msg="FTS rebuild chunk failed (will retry): %s",
- finish=self._fts_rebuild_finish, finish_when_empty=True)
+ return self._rebuild_step("fts_rebuild", inserts, finish=self._fts_rebuild_finish, finish_when_empty=True)
def fts_cjk_rebuild_step(self) -> bool:
"""Backfill one chunk of the CJK index. True while work remains."""
if not self._fts_enabled or not self._fts_cjk_loaded:
return False
insert = self._CHUNK_INSERT_SQL.format(table="messages_fts_cjk", extra=" AND role <> 'tool'")
- return self._rebuild_step("fts_cjk_rebuild", [insert], finish=self._fts_cjk_rebuild_finish,
- fail_msg="CJK FTS rebuild chunk failed (will retry): %s")
+ return self._rebuild_step("fts_cjk_rebuild", [insert], finish=self._fts_cjk_rebuild_finish)
- def _rebuild_step(self, prefix: str, insert_sqls: list[str], *, fail_msg: str, finish,
- finish_when_empty: bool = False) -> bool:
+ def _rebuild_step(self, prefix: str, insert_sqls: list[str], *, finish, finish_when_empty: bool = False) -> bool:
"""Shared chunk engine for the base and CJK deferred backfills. ``finish_when_empty``
finalizes a high_water <= 0 marker (empty messages table) instead of leaving it pending."""
high_water_raw = self.get_meta(f"{prefix}_high_water")
@@ -355,19 +389,18 @@ class SessionSearchMixin:
progress = int(row[0])
if progress >= high_water:
return False
- # Upper bound is an id, not a row count, so deleted-row gaps don't shrink chunks.
- upper = min(progress + chunk, high_water)
+ # The window is the next `chunk` messages, not `chunk` ids: an id gap (pruned
+ # sessions) neither shrinks a chunk nor lets the next one span a million dense ids.
+ nth = conn.execute("SELECT id FROM messages WHERE id > ? AND id <= ? ORDER BY id LIMIT 1 OFFSET ?",
+ (progress, high_water, chunk - 1)).fetchone()
+ upper = int(nth[0]) if nth is not None else high_water
for sql in insert_sqls:
conn.execute(sql, (progress, upper))
# Progress lands in the same transaction as its rows (crash-atomic).
conn.execute("UPDATE state_meta SET value = ? WHERE key = ?", (str(upper), f"{prefix}_progress"))
return upper < high_water
- try:
- more = self._execute_write(_do)
- except sqlite3.OperationalError as exc:
- logger.debug(fail_msg, exc)
- return True # transient (lock contention) — caller retries
+ more = self._execute_write(_do)
if more is False:
status = self._rebuild_status(prefix)
if (finish_when_empty and high_water <= 0) or (
@@ -377,10 +410,13 @@ class SessionSearchMixin:
return False
return bool(more)
- def _fts_teardown_trash_step(self, progress: Optional[dict] = None) -> bool:
+ def _fts_teardown_trash_step(self, progress: Optional[_TeardownProgress] = None) -> bool:
"""Tear down one chunk (``_fts_chunk_rows`` rows) of a demoted v22 FTS shadow table (a PLAIN
- table now); True while work remains. ``progress``, when given, is kept current as {"table", "table_index",
- "table_count", "drained", "total"} for the table being drained.
+ table now); True while work remains. A failed chunk raises (nothing committed).
+
+ With *progress* (optimize-storage), the first step on each trash table deletes nothing: it
+ points *progress* at the table and restarts the chunk size (rows cost ~1000x more in one
+ shadow table than in another); later steps add the rows they deleted.
Single-column INTEGER-key trash tables (the common shape — FTS shadow tables carry a rowid/integer
PK) are drained with a high-water marker mirroring :meth:`fts_rebuild_step`: each chunk deletes only
@@ -394,6 +430,9 @@ class SessionSearchMixin:
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE ? ESCAPE '\\'",
(self._FTS_TRASH_PREFIX.replace("_", "\\_") + "%",),
).fetchall()]
+ if trash and progress is not None and progress.table != trash[0]:
+ self._fts_teardown_start_table(conn, trash, progress)
+ return True
if not trash:
return False
tbl = trash[0]
@@ -434,34 +473,24 @@ class SessionSearchMixin:
logger.info("Old FTS shadow table %s torn down.", tbl)
return True
- try:
- more, deleted = self._execute_write(_do)
- except sqlite3.OperationalError as exc:
- logger.debug("FTS trash teardown chunk failed (will retry): %s", exc)
- return True
+ more, deleted = self._execute_write(_do)
if progress is not None:
# Counted after the commit: _execute_write may replay _do on contention.
- progress["drained"] = progress.get("drained", 0) + deleted
+ progress.drained += deleted
return bool(more)
- def _fts_teardown_track(self, progress: dict) -> None:
- """Point *progress* at the trash table the next teardown chunk drains: its position in the run
- and the rows left in it. One COUNT per table, on the reader (no writer waits on it) and outside
- the timed chunk (so it never skews the chunk sizing)."""
- with self._read_ctx() as conn:
- trash = [r[0] for r in conn.execute(
- "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE ? ESCAPE '\\'",
- (self._FTS_TRASH_PREFIX.replace("_", "\\_") + "%",),
- ).fetchall()]
- if not trash or progress.get("table") == trash[0]:
- return
- tbl = trash[0]
- # Only INTEGER-pk tables (rowid aliases) carry a resume marker; count what is left past it.
- marker = _meta_row(conn, f"fts_teardown_{tbl}_progress")
- total = conn.execute(f"SELECT COUNT(*) FROM {tbl} WHERE rowid > ?", (int(marker[0]),)).fetchone()[0] \
- if marker is not None else conn.execute(f"SELECT COUNT(*) FROM {tbl}").fetchone()[0]
- count = progress.setdefault("table_count", len(trash))
- progress.update(table=tbl, table_index=max(1, count - len(trash) + 1), drained=0, total=int(total))
+ def _fts_teardown_start_table(self, conn, trash: list[str], progress: _TeardownProgress) -> None:
+ """Point *progress* at ``trash[0]`` (its place in the run, the rows left past its resume
+ marker) and restart the chunk size. One COUNT per table, on the pooled reader *conn*."""
+ tbl = trash[0]
+ # Only INTEGER-pk tables (rowid aliases) carry a resume marker; count what is left past it.
+ marker = _meta_row(conn, f"fts_teardown_{tbl}_progress")
+ where, params = ("WHERE rowid > ?", (int(marker[0]),)) if marker is not None else ("", ())
+ progress.table_count = progress.table_count or len(trash)
+ progress.table, progress.table_index = tbl, max(1, progress.table_count - len(trash) + 1)
+ progress.total = int(conn.execute(f"SELECT COUNT(*) FROM {tbl} {where}", params).fetchone()[0])
+ progress.drained = 0
+ self._fts_chunk_rows = self._FTS_REBUILD_CHUNK_ROWS
def _fts_cjk_reset_if_stale(self) -> None:
"""From-scratch rebuild of a stale cjk index (triggers were dropped, gap extent unknown):
@@ -631,43 +660,64 @@ class SessionSearchMixin:
raise sqlite3.OperationalError(failure_message)
self._conn.commit()
- def _optimize_progress_info(self, phase: str, track: dict) -> dict[str, Any]:
- """``progress_cb`` payload: {"phase", "percent", "indexed", "total"} for every phase, plus
- {"table", "table_index", "table_count"} while tearing down and {"elapsed", "size_bytes",
- "estimated_bytes"} for the VACUUM (one statement: elapsed time, not a percent)."""
- if phase == "teardown" and track.get("table"):
- done, total = track["drained"], track["total"]
- return {"phase": phase, "percent": min(100, 100 * done // total) if total else 100, "indexed": done,
- "total": total, **{k: track[k] for k in ("table", "table_index", "table_count")}}
- if phase == "vacuum" and "vacuum_started" in track:
- return {"phase": phase, "percent": 0, "indexed": 0, "total": 0,
- "elapsed": time.monotonic() - track["vacuum_started"],
- **{k: track[k] for k in ("size_bytes", "estimated_bytes")}}
+ def _backfill_progress_info(self, phase: str = "backfill") -> dict[str, Any]:
st = self.fts_rebuild_status() or self.fts_cjk_rebuild_status()
return {"phase": phase, "percent": st["percent"] if st else 100,
"indexed": st["indexed"] if st else 0, "total": st["total"] if st else 0}
- def _optimize_vacuum(self, track: dict, tick: Optional[Callable[[], None]] = None) -> bool:
+ # A chunk that fails this many times in a row ends the run (each lock failure already waited out
+ # _execute_write's patience); a full disk or I/O error never heals by retrying.
+ _OPTIMIZE_CHUNK_ATTEMPTS = 3
+ _VACUUM_TICK_S = 1.0
+
+ def _optimize_drive(self, step: Callable[[], bool], emit: Callable[[], None], *, paced: bool) -> None:
+ """Run *step* to completion, emitting after every chunk. Back to back (no other writer: see
+ the chunk engine note in hermes_state_fts) unless *paced*, which keeps the duty cycle for a
+ holder ``--force`` ran past. Chunk size adapts to its measured cost; a step may restart it
+ (teardown on each new table)."""
+ target = self._FTS_REBUILD_PACED_TARGET_S if paced else self._FTS_REBUILD_CHUNK_TARGET_S
+ self._fts_chunk_rows = self._FTS_REBUILD_CHUNK_ROWS
+ failures = 0
+ while True:
+ t0 = time.monotonic()
+ try:
+ more = step()
+ except sqlite3.OperationalError as exc:
+ failures += 1
+ if failures >= self._OPTIMIZE_CHUNK_ATTEMPTS:
+ raise
+ logger.warning("FTS optimize chunk failed (attempt %d, retrying): %s", failures, exc)
+ time.sleep(0.25 * failures)
+ continue
+ failures = 0
+ if not more:
+ return
+ cost = time.monotonic() - t0
+ emit()
+ self._fts_chunk_rows = self._next_chunk_rows(self._fts_chunk_rows, cost, target)
+ if paced:
+ time.sleep(max(self._FTS_REBUILD_MIN_PAUSE, cost * self._FTS_REBUILD_DUTY_FACTOR))
+
+ def _optimize_vacuum(self, tick: Optional[Callable[[dict[str, Any]], None]] = None) -> bool:
"""Phase 3: reclaim freed pages to the OS. False when VACUUM failed (usually no free disk
for its temp copy; a later VACUUM reclaims). VACUUM is one statement that runs for minutes
- on a big store, so *tick* is called every second from a side thread to show it is alive;
- *track* gets the size before and the estimated size after (live pages only)."""
- with self._lock:
- page_size, pages, free = (self._conn.execute(f"PRAGMA {p}").fetchone()[0]
- for p in ("page_size", "page_count", "freelist_count"))
- track.update(vacuum_started=time.monotonic(), size_bytes=pages * page_size,
- estimated_bytes=(pages - free) * page_size)
- stop = threading.Event()
-
- def _tick() -> None:
- tick()
- while not stop.wait(1.0):
- tick()
-
- from agent.memory_provider import spawn_context_thread
-
- ticker = spawn_context_thread(_tick, name="optimize-vacuum-ticker")
+ on a big store, so *tick* gets a :class:`_VacuumProgress` payload at once and every
+ ``_VACUUM_TICK_S`` from a side thread, which is stopped and joined before this returns."""
+ ticker, stop = None, threading.Event()
if tick is not None:
+ with self._read_ctx() as conn:
+ page_size, pages, free = (conn.execute(f"PRAGMA {p}").fetchone()[0]
+ for p in ("page_size", "page_count", "freelist_count"))
+ state = _VacuumProgress(size_bytes=pages * page_size, estimated_bytes=(pages - free) * page_size)
+
+ def _tick() -> None:
+ tick(state.info())
+ while not stop.wait(self._VACUUM_TICK_S):
+ tick(state.info())
+
+ # copy_context().run: the ticker keeps the caller's profile scope (HX012).
+ ticker = threading.Thread(target=contextvars.copy_context().run, args=(_tick,),
+ name="optimize-vacuum-ticker", daemon=True)
ticker.start()
try:
with self._lock:
@@ -678,7 +728,7 @@ class SessionSearchMixin:
vacuum_ok = False
finally:
stop.set()
- if ticker.is_alive():
+ if ticker is not None:
ticker.join()
# Best-effort WAL fold-back, REFUSED (SQLITE_BUSY) while another connection holds a
# read-mark (callers size via logical_size_bytes, not stat()). PASSIVE, never TRUNCATE:
@@ -718,13 +768,16 @@ class SessionSearchMixin:
return None
def optimize_fts_storage(
- self, *, progress_cb: Optional[Callable[[dict[str, Any]], None]] = None, vacuum: bool = True
+ self, *, progress_cb: Optional[Callable[[dict[str, Any]], None]] = None, vacuum: bool = True,
+ yield_to_writers: bool = False,
) -> dict[str, Any]:
"""Repair an older FTS layout into the current v23 shape, foreground and to completion:
legacy-v22 inline -> external-content, or a v23 ``messages_fts_trigram`` that still stores
- ``tool_calls``. Re-running resumes. ``progress_cb`` receives :meth:`_optimize_progress_info`
- after every chunk and every second of the VACUUM. A missing trigram tokenizer is not fatal
- (CJK falls back to LIKE)."""
+ ``tool_calls``. Re-running resumes; a chunk that keeps failing raises (nothing is lost).
+ ``progress_cb`` receives {"phase", "percent", "indexed", "total"} after every chunk (plus
+ :meth:`_TeardownProgress.info` / :meth:`_VacuumProgress.info` fields) and every second of the
+ VACUUM. ``yield_to_writers`` (``--force`` past a live holder) paces chunks so its writes get
+ through. A missing trigram tokenizer is not fatal (CJK falls back to LIKE)."""
if not self._fts_enabled:
return {"ok": False, "reason": "fts5_unavailable"}
if self.read_only:
@@ -748,35 +801,15 @@ class SessionSearchMixin:
if self._fts_cjk_loaded:
self._ensure_cjk_schema_committed()
- track: dict[str, Any] = {} # teardown table/rows and VACUUM sizes, for progress_cb
-
- def _emit(phase: str) -> None:
- if progress_cb is not None:
- progress_cb(self._optimize_progress_info(phase, track))
-
- def _drive(phase: str, step, before=None, **step_kwargs) -> None:
- """Run *step* to completion, back to back (no other writer: see the chunk engine
- note in hermes_state_fts). Chunk size adapts to its measured cost."""
- self._fts_chunk_rows = self._FTS_REBUILD_CHUNK_ROWS
- while True:
- if before is not None:
- before()
- _t0 = time.monotonic()
- if not step(**step_kwargs):
- break
- cost = time.monotonic() - _t0
- _emit(phase)
- self._fts_chunk_rows = self._next_chunk_rows(self._fts_chunk_rows, cost)
-
+ emit = progress_cb or (lambda _info: None)
+ teardown = _TeardownProgress()
# Phase 1: base backfill; 1b: CJK-bigram backfill (own marker pair).
- _emit("backfill")
- _drive("backfill", self.fts_rebuild_step)
- _drive("backfill", self.fts_cjk_rebuild_step)
+ emit(self._backfill_progress_info())
+ for step in (self.fts_rebuild_step, self.fts_cjk_rebuild_step):
+ self._optimize_drive(step, lambda: emit(self._backfill_progress_info()), paced=yield_to_writers)
# Phase 2: tear down the demoted legacy shadow tables in chunks.
- self._fts_teardown_track(track)
- _emit("teardown")
- _drive("teardown", self._fts_teardown_trash_step, before=lambda: self._fts_teardown_track(track),
- progress=track)
+ self._optimize_drive(lambda: self._fts_teardown_trash_step(teardown), lambda: emit(teardown.info()),
+ paced=yield_to_writers)
with self._read_ctx() as conn:
still_pending = _meta_row(conn, "fts_rebuild_high_water") is not None
still_trash = self._has_fts_trash(conn)
@@ -789,13 +822,13 @@ class SessionSearchMixin:
vacuum_ok = None
if vacuum:
- vacuum_ok = self._optimize_vacuum(track, None if progress_cb is None else lambda: _emit("vacuum"))
+ vacuum_ok = self._optimize_vacuum(progress_cb)
refusal = self._execute_write(self._optimize_settle)
if refusal is not None:
# A concurrent process changed state since the pre-vacuum check; a re-run can still settle.
logger.warning("FTS storage optimization settle refused (%s)", refusal)
return {"ok": False, "reason": refusal, "vacuumed": vacuum_ok}
- _emit("done")
+ emit(self._backfill_progress_info("done"))
logger.info("FTS storage optimization complete (layout v%d).", FTS_STORAGE_VERSION)
return {"ok": True, "vacuumed": vacuum_ok}
diff --git a/tests/hermes_state/test_fts_optimize_progress.py b/tests/hermes_state/test_fts_optimize_progress.py
index a66a79cfcc..33137e7db8 100644
--- a/tests/hermes_state/test_fts_optimize_progress.py
+++ b/tests/hermes_state/test_fts_optimize_progress.py
@@ -1,7 +1,10 @@
"""`hermes sessions optimize-storage` runs in the foreground behind the held-store refusal, so it must
-report real progress in every phase and never idle between chunks."""
+report real progress in every phase, never idle between chunks, and end (not spin) when a chunk
+cannot succeed."""
+import itertools
import sqlite3
+import threading
import time
import pytest
@@ -9,15 +12,18 @@ import pytest
from hermes_state import SessionDB
from hermes_state_common import LEGACY_FTS_SQL, SCHEMA_SQL
+GAP_AFTER, GAP_IDS = 600, 100_000 # a pruned store: ids jump after the first 600 messages
+
def _legacy_inline_store(db_path, n=1200):
+ ids = [i + 1 + (GAP_IDS if i >= GAP_AFTER else 0) for i in range(n)]
raw = sqlite3.connect(str(db_path))
try:
raw.executescript(SCHEMA_SQL + LEGACY_FTS_SQL)
raw.execute("INSERT INTO sessions (id, source, started_at) VALUES ('s1', 'cli', ?)", (time.time(),))
raw.executemany(
- "INSERT INTO messages (session_id, timestamp, role, content) VALUES ('s1', ?, 'user', ?)",
- [(time.time(), f"legacy payload {i} zebra quokka") for i in range(n)],
+ "INSERT INTO messages (id, session_id, timestamp, role, content) VALUES (?, 's1', ?, 'user', ?)",
+ [(mid, time.time(), f"legacy payload {mid} zebra quokka") for mid in ids],
)
raw.commit()
finally:
@@ -33,22 +39,85 @@ def legacy_db(tmp_path):
db.close()
-def test_teardown_reports_which_table_and_how_many_rows(legacy_db):
- events = []
- result = legacy_db.optimize_fts_storage(progress_cb=events.append, vacuum=False)
- assert result["ok"] is True
+@pytest.mark.parametrize("vacuum_fails", [False, True], ids=["vacuum-ok", "vacuum-fails"])
+def test_progress_reports_teardown_rows_and_vacuum_liveness(legacy_db, monkeypatch, vacuum_fails):
+ monkeypatch.setattr(SessionDB, "_VACUUM_TICK_S", 0.01)
+ events, in_vacuum, ticked_in_vacuum = [], threading.Event(), threading.Event()
+
+ def progress(info):
+ events.append(info)
+ if info["phase"] == "vacuum" and in_vacuum.is_set():
+ ticked_in_vacuum.set()
+
+ # Hold the VACUUM statement (SQLite's own progress hook) until a tick arrives while it runs.
+ vacuum_started = []
+ legacy_db._conn.set_trace_callback(lambda sql: vacuum_started.append(sql) if sql.strip() == "VACUUM" else None)
+
+ def hold_vacuum():
+ if not vacuum_started or in_vacuum.is_set():
+ return 0
+ in_vacuum.set()
+ ticked_in_vacuum.wait(10)
+ return 1 if vacuum_fails else 0 # non-zero interrupts the VACUUM
+
+ legacy_db._conn.set_progress_handler(hold_vacuum, 100)
+ result = legacy_db.optimize_fts_storage(progress_cb=progress, vacuum=True)
+
+ assert result == {"ok": True, "vacuumed": not vacuum_fails}
+ assert ticked_in_vacuum.is_set()
+ # The ticker is stopped and joined on success and on failure: nothing can report after return.
+ assert not [t for t in threading.enumerate() if t.name == "optimize-vacuum-ticker"]
+ assert events[-1]["phase"] == "done"
+ vacuum = [e for e in events if e["phase"] == "vacuum"]
+ assert vacuum[0]["size_bytes"] >= vacuum[0]["estimated_bytes"] > 0
teardown = [e for e in events if e["phase"] == "teardown"]
# The docsize shadow table holds one row per message: its drain must be visible as rows, not a bare label.
- docsize = [e for e in teardown if e.get("table", "").endswith("messages_fts_docsize")]
- assert docsize and docsize[0]["total"] == 1200
+ docsize = [e for e in teardown if e["table"].endswith("messages_fts_docsize")]
+ assert docsize[0]["total"] == 1200 and docsize[0]["indexed"] == 0
assert docsize[-1]["indexed"] == 1200 and docsize[-1]["percent"] == 100
count = teardown[0]["table_count"]
assert count >= 2 and {e["table_index"] for e in teardown} <= set(range(1, count + 1))
assert legacy_db.search_messages("quokka")
-def test_foreground_optimize_never_sleeps_between_chunks(legacy_db, monkeypatch):
+def test_foreground_optimize_never_sleeps_and_chunks_by_rows_across_id_gaps(legacy_db, monkeypatch):
slept = []
monkeypatch.setattr(time, "sleep", slept.append)
+ monkeypatch.setattr(SessionDB, "_FTS_REBUILD_CHUNK_MIN_ROWS", 500)
+ monkeypatch.setattr(SessionDB, "_FTS_REBUILD_CHUNK_MAX_ROWS", 500)
+ markers, real_step = [0], legacy_db.fts_rebuild_step
+
+ def observed_step():
+ more = real_step()
+ status = legacy_db.fts_rebuild_status()
+ markers.append(status["indexed"] if status else GAP_IDS + 1200)
+ return more
+
+ legacy_db.fts_rebuild_step = observed_step
assert legacy_db.optimize_fts_storage(vacuum=False)["ok"] is True
assert slept == []
+ with legacy_db._read_ctx() as conn:
+ windows = [conn.execute("SELECT COUNT(*) FROM messages WHERE id > ? AND id <= ?", (lo, hi)).fetchone()[0]
+ for lo, hi in itertools.pairwise(markers)]
+ # 500-row chunks: the 100k-id gap costs no extra chunk and never widens one past 500 messages.
+ assert windows == [500, 500, 200]
+
+
+def test_chunk_that_keeps_failing_ends_the_run_and_a_rerun_resumes(legacy_db, monkeypatch):
+ monkeypatch.setattr(time, "sleep", lambda _s: None) # the retry backoff
+ calls, real_step = [], legacy_db.fts_rebuild_step
+
+ def disk_fills_during_backfill():
+ if not calls:
+ pages = legacy_db._conn.execute("PRAGMA page_count").fetchone()[0]
+ legacy_db._conn.execute(f"PRAGMA max_page_count = {pages + 5}")
+ calls.append(1)
+ assert len(calls) < 50, "optimize kept retrying a chunk that can never succeed"
+ return real_step()
+
+ legacy_db.fts_rebuild_step = disk_fills_during_backfill
+ with pytest.raises(sqlite3.OperationalError, match="full"):
+ legacy_db.optimize_fts_storage(vacuum=False)
+ legacy_db._conn.execute("PRAGMA max_page_count = 1073741823")
+ assert legacy_db.optimize_fts_storage(vacuum=False)["ok"] is True
+ assert len(legacy_db.search_messages("quokka", limit=2000)) == 1200
From a85ffc0e2479cb453a3655e832c9956a33845a3e Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 14:21:50 -0700
Subject: [PATCH 010/114] fix(sessions): teardown progress survives a failed
table count; backfill counter shows ids
A COUNT that failed once left the new table published on the progress
state, so the retry skipped the start step and the table inherited the
previous table's totals (shown as 10,726/2,726). Count first, publish the
table last. The backfill counter is id-based, so drop the "messages"
label that misread on pruned stores.
---
hermes_cli/sessions_cmd.py | 2 +-
hermes_state_search.py | 11 +++++++----
2 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py
index 81fc9b9efe..bbe79a1bae 100644
--- a/hermes_cli/sessions_cmd.py
+++ b/hermes_cli/sessions_cmd.py
@@ -984,7 +984,7 @@ def _optimize_progress_line(info) -> str:
phase, pct = info.get("phase"), f"{info.get('percent', 0):3d}%"
counts = f"({info.get('indexed', 0):,}/{info.get('total', 0):,}"
if phase == "backfill":
- return f" Rebuilding index: {pct} {counts} messages)"
+ return f" Rebuilding index: {pct} {counts})"
if phase == "teardown":
table = str(info.get("table", "")).removeprefix("fts_v22_trash_")
return (f" Reclaiming old index: table {info.get('table_index', 0)}/{info.get('table_count', 0)} "
diff --git a/hermes_state_search.py b/hermes_state_search.py
index 2493d38a2a..efdefc1c07 100644
--- a/hermes_state_search.py
+++ b/hermes_state_search.py
@@ -486,11 +486,14 @@ class SessionSearchMixin:
# Only INTEGER-pk tables (rowid aliases) carry a resume marker; count what is left past it.
marker = _meta_row(conn, f"fts_teardown_{tbl}_progress")
where, params = ("WHERE rowid > ?", (int(marker[0]),)) if marker is not None else ("", ())
- progress.table_count = progress.table_count or len(trash)
- progress.table, progress.table_index = tbl, max(1, progress.table_count - len(trash) + 1)
- progress.total = int(conn.execute(f"SELECT COUNT(*) FROM {tbl} {where}", params).fetchone()[0])
- progress.drained = 0
+ table_count = progress.table_count or len(trash)
+ # COUNT before publishing the table: a failed COUNT must leave progress on the previous
+ # table so the retry runs this start step again instead of inheriting its totals.
+ total = int(conn.execute(f"SELECT COUNT(*) FROM {tbl} {where}", params).fetchone()[0])
+ progress.table_count, progress.total, progress.drained = table_count, total, 0
+ progress.table_index = max(1, table_count - len(trash) + 1)
self._fts_chunk_rows = self._FTS_REBUILD_CHUNK_ROWS
+ progress.table = tbl
def _fts_cjk_reset_if_stale(self) -> None:
"""From-scratch rebuild of a stale cjk index (triggers were dropped, gap extent unknown):
From d569365e87e2cb6e3ccb5a4b001b1bcf38dffc56 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 17:21:41 -0700
Subject: [PATCH 011/114] fix(sessions): optimize-storage paces for a writer
that opens state.db mid-run; failed runs exit 1
Review round (@yoniebans) on #144630:
- Pacing was decided once at start, so a writer that opened state.db after the
run began (Desktop launched, gateway restarted) only got in through SQLite's
own gaps (one append waited 16.7 s with wal_autocheckpoint=0, next to
_execute_write's 20 s patience). _HolderWatch re-runs the same foreign-holder
scan the held-store refusal and --force use (foreign_state_db_holders, ~15 ms)
every 2 s on a side thread (copy_context, HX012); the chunk loop reads the flag
and switches to the paced cycle (~50 ms chunks + max(0.2 s, 4x cost) pause)
while a holder is there, back to unpaced when it leaves. A quiet run still
never sleeps. The CLI's one-shot scan and yield_to_writers go away; progress
lines say "paced: another process is using the database".
- A failed run (chunk kept failing, or "Could not optimize") now returns 1.
- Backfill progress counts messages (rows each window covered, total = rows at or
below the high water), not ids: a 100k id gap no longer reads 0% then 99%.
- The CJK backfill gets its own phase/line ("Building CJK index") instead of
overwriting the base pass's line with a lower percent.
- Teardown skips the per-table COUNT(*) when no progress callback is registered.
- Tests build the legacy store with both inline indexes (base + trigram), so the
trigram rebuild and all 10 reclaimed tables run; exit code 1 is asserted in the
disk-full test; one new test covers a holder appearing mid-run.
---
hermes_cli/sessions_cmd.py | 34 ++--
hermes_cli/subcommands/sessions.py | 4 +-
hermes_state_fts.py | 19 +-
hermes_state_search.py | 162 +++++++++++++-----
.../test_fts_optimize_progress.py | 60 ++++++-
.../user-guide/session-storage-recovery.md | 4 +-
6 files changed, 203 insertions(+), 80 deletions(-)
diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py
index bbe79a1bae..4881375d60 100644
--- a/hermes_cli/sessions_cmd.py
+++ b/hermes_cli/sessions_cmd.py
@@ -980,15 +980,20 @@ def _cmd_clean_markers(db, args):
print(f"✓ Cleared {report['rows_affected']} row(s).")
+_BACKFILL_LABELS = {"backfill": "Rebuilding index", "backfill_cjk": "Building CJK index"}
+_COUNTED_PHASES = frozenset({*_BACKFILL_LABELS, "teardown"})
+
+
def _optimize_progress_line(info) -> str:
phase, pct = info.get("phase"), f"{info.get('percent', 0):3d}%"
counts = f"({info.get('indexed', 0):,}/{info.get('total', 0):,}"
- if phase == "backfill":
- return f" Rebuilding index: {pct} {counts})"
+ paced = " — paced: another process is using the database" if info.get("paced") else ""
+ if phase in _BACKFILL_LABELS:
+ return f" {_BACKFILL_LABELS[phase]}: {pct} {counts} messages){paced}"
if phase == "teardown":
table = str(info.get("table", "")).removeprefix("fts_v22_trash_")
return (f" Reclaiming old index: table {info.get('table_index', 0)}/{info.get('table_count', 0)} "
- f"({table}) {pct} {counts} rows)")
+ f"({table}) {pct} {counts} rows){paced}")
elapsed = int(info.get("elapsed", 0))
return (f" Compacting database (VACUUM): {elapsed // 60}m {elapsed % 60:02d}s elapsed, "
f"{info.get('size_bytes', 0) / 2**20:,.1f} MB → ~{info.get('estimated_bytes', 0) / 2**20:,.1f} MB")
@@ -1008,7 +1013,7 @@ class _OptimizeProgress:
# The phase or table moved on, so the previous one ran to completion.
self.close(completed=True)
self._key = key
- if phase == "done" or (phase in ("backfill", "teardown") and not info.get("total") and not info.get("table")):
+ if phase == "done" or (phase in _COUNTED_PHASES and not info.get("total") and not info.get("table")):
return
self._last = info
self._draw(_optimize_progress_line(info))
@@ -1021,19 +1026,13 @@ class _OptimizeProgress:
def close(self, completed: bool = False) -> None:
last = self._last
- if completed and last and last.get("phase") in ("backfill", "teardown"):
+ if completed and last and last.get("phase") in _COUNTED_PHASES:
self._draw(_optimize_progress_line({**last, "percent": 100, "indexed": last.get("total", 0)}))
if self._line:
print(flush=True)
self._last, self._line = None, ""
-def _store_has_foreign_holder(db_path) -> bool:
- from hermes_state_holders import held_store_refusal
-
- return held_store_refusal(db_path, command="optimize-storage") is not None
-
-
def _cmd_optimize_storage(db, args):
db_path = db.db_path
if not db.fts_optimize_available():
@@ -1068,15 +1067,12 @@ def _cmd_optimize_storage(db, args):
if resp not in ("y", "yes"):
print("Cancelled.")
return
- # The holder check was skipped under --force: if someone really holds the store, keep the old
- # duty-cycle pause so their writes are not starved (slower, but they stay responsive).
- yield_to_writers = bool(getattr(args, "force", False)) and _store_has_foreign_holder(db_path)
- if yield_to_writers:
- print(" Another process is using the database (--force): pacing the rebuild so it stays responsive.")
+ # optimize_fts_storage re-scans for holders itself (--force past one, or a writer that opens the
+ # store mid-run) and paces while one is there; the progress line says so.
progress = _OptimizeProgress()
print("Optimizing search-index storage…")
try:
- result = db.optimize_fts_storage(progress_cb=progress, vacuum=do_vacuum, yield_to_writers=yield_to_writers)
+ result = db.optimize_fts_storage(progress_cb=progress, vacuum=do_vacuum)
except KeyboardInterrupt:
progress.close()
print("Interrupted — no data was lost; re-run to resume.")
@@ -1084,11 +1080,11 @@ def _cmd_optimize_storage(db, args):
except Exception as e:
progress.close()
print(f"Error: optimization failed: {e}\nNo data was lost. Re-run to resume.")
- return
+ return 1
progress.close()
if not result.get("ok"):
print(f"Could not optimize: {result.get('reason', 'unknown')}")
- return
+ return 1
print("✓ Search index optimized.")
_print_size_change(db, before_mb, prefix=" ")
if result.get("vacuumed") is False:
diff --git a/hermes_cli/subcommands/sessions.py b/hermes_cli/subcommands/sessions.py
index 4dc9388e1d..9f6ce24044 100644
--- a/hermes_cli/subcommands/sessions.py
+++ b/hermes_cli/subcommands/sessions.py
@@ -172,8 +172,8 @@ def build_sessions_parser(subparsers, *, cmd_sessions: Callable) -> None:
"large fraction of state.db (the old layout stored duplicate "
"copies of every message and indexed tool output). Runs "
"foreground with progress for every phase (refusing while a gateway "
- "or other Hermes process holds state.db) and VACUUMs at the end. Safe to interrupt and "
- "re-run — it resumes where it left off. No conversation data is "
+ "or other Hermes process holds state.db, pacing itself if one opens it mid-run) and "
+ "VACUUMs at the end. Safe to interrupt and re-run — it resumes where it left off. No conversation data is "
"changed; only the search index is rebuilt.")
_flag(sessions_optimize_storage, "--no-vacuum", default=False,
help="Skip the final VACUUM (index is rebuilt but freed pages aren't returned to the OS until a later VACUUM)")
diff --git a/hermes_state_fts.py b/hermes_state_fts.py
index 00b51a8669..f3204fed50 100644
--- a/hermes_state_fts.py
+++ b/hermes_state_fts.py
@@ -538,15 +538,16 @@ class SessionFtsSetupMixin:
# One blocking rebuild held the write lock ~16 min on a 25 GB DB, so the
# backfill and teardown run in chunks: each its own bounded transaction (WAL
# growth stays small), resumable from its marker (claimed by CAS), and a
- # progress tick. They run back to back with no pause: the only driver is the
- # foreground `hermes sessions optimize-storage`, which refuses while any other
- # process holds state.db (held_store_refusal, #110054), so there is no writer
- # to yield to; a fixed 0.2 s pause after each few-ms chunk was >95% of the
- # wall time. Chunk size (in ROWS, never an id span: a pruned store has id gaps)
- # adapts toward TARGET seconds, since rows cost ~1000x more in trigram_data
- # than in docsize. `--force` past a live holder (yield_to_writers) keeps the
- # duty cycle instead: chunks near PACED_TARGET, a pause of max(MIN_PAUSE,
- # cost x DUTY_FACTOR) after each, so that holder's writes are not starved.
+ # progress tick. They run back to back with no pause while no other process
+ # holds state.db: the foreground `hermes sessions optimize-storage` refuses to
+ # start under a holder (held_store_refusal, #110054), and a fixed 0.2 s pause
+ # after each few-ms chunk was >95% of the wall time. Chunk size (in ROWS, never
+ # an id span: a pruned store has id gaps) adapts toward TARGET seconds, since
+ # rows cost ~1000x more in trigram_data than in docsize. A side thread re-runs
+ # the holder scan every _HOLDER_SCAN_INTERVAL_S (`--force` past a holder, or a
+ # writer that opens the store mid-run): while one is there the duty cycle comes
+ # back — chunks near PACED_TARGET, a pause of max(MIN_PAUSE, cost x
+ # DUTY_FACTOR) after each — so that holder's writes are not starved.
_FTS_REBUILD_CHUNK_ROWS = 500 # first chunk of every phase and trash table; later ones adapt
_FTS_REBUILD_CHUNK_MIN_ROWS = 50
_FTS_REBUILD_CHUNK_MAX_ROWS = 100_000
diff --git a/hermes_state_search.py b/hermes_state_search.py
index efdefc1c07..bfc75aecf7 100644
--- a/hermes_state_search.py
+++ b/hermes_state_search.py
@@ -12,6 +12,7 @@ import sqlite3
import threading
import time
from dataclasses import dataclass, field
+from functools import partial
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple
from agent.skill_commands import describe_skill_invocation
@@ -28,16 +29,35 @@ from hermes_state_fts import fts_layout_current, fts_layout_drift
logger = logging.getLogger("hermes_state")
+@dataclass
+class _BackfillProgress:
+ """How many messages a backfill pass (``backfill`` = base + trigram, ``backfill_cjk``) has
+ indexed out of the messages at or below its high water. Counts rows, never ids: a pruned
+ store's id gaps would otherwise read 0% then jump to 99%. The step engine adds the rows each
+ window covered (:meth:`SessionSearchMixin._rebuild_step`)."""
+
+ phase: str
+ total: int = 0
+ indexed: int = 0
+
+ def info(self) -> dict[str, Any]:
+ done, total = self.indexed, self.total
+ return {"phase": self.phase, "percent": min(100, 100 * done // total) if total else 100,
+ "indexed": min(done, total), "total": total}
+
+
@dataclass
class _TeardownProgress:
"""Which demoted shadow table optimize-storage is draining and how far (``progress_cb``
- payload). Owned and kept current by :meth:`SessionSearchMixin._fts_teardown_trash_step`."""
+ payload). Owned and kept current by :meth:`SessionSearchMixin._fts_teardown_trash_step`.
+ ``counted=False`` (no progress callback) skips the per-table ``COUNT(*)``."""
table: str = ""
table_index: int = 0
table_count: int = 0
total: int = 0
drained: int = 0
+ counted: bool = True
def info(self) -> dict[str, Any]:
done, total = self.drained, self.total
@@ -61,6 +81,43 @@ class _VacuumProgress:
"estimated_bytes": self.estimated_bytes}
+class _HolderWatch:
+ """Re-runs the foreign-holder scan (the one the held-store refusal and ``--force`` use) every
+ *interval* seconds on a side thread while optimize-storage rebuilds and tears down. The chunk
+ loop only reads :attr:`held`, so a quiet store pays nothing per chunk, while a writer that opens
+ state.db mid-run (Desktop launched, gateway restarted) moves the loop onto the paced cycle within
+ one interval instead of waiting behind back-to-back chunks."""
+
+ def __init__(self, scan: Callable[[], list], interval: float):
+ self._scan, self._interval = scan, interval
+ self._held, self._stop = threading.Event(), threading.Event()
+ self._thread: Optional[threading.Thread] = None
+
+ @property
+ def held(self) -> bool:
+ return self._held.is_set()
+
+ def _refresh(self) -> None:
+ (self._held.set if self._scan() else self._held.clear)()
+
+ def _run(self) -> None:
+ while not self._stop.wait(self._interval):
+ self._refresh()
+
+ def __enter__(self) -> "_HolderWatch":
+ self._refresh() # a holder already there paces from the first chunk
+ # copy_context().run: the watcher keeps the caller's profile scope (HX012).
+ self._thread = threading.Thread(target=contextvars.copy_context().run, args=(self._run,),
+ name="optimize-holder-watch", daemon=True)
+ self._thread.start()
+ return self
+
+ def __exit__(self, *_exc) -> None:
+ self._stop.set()
+ if self._thread is not None:
+ self._thread.join()
+
+
def _search_slow_ms() -> float:
"""``sessions.search_slow_ms`` for the served profile (default 1000; 0 logs every call)."""
@@ -352,26 +409,28 @@ class SessionSearchMixin:
_delete_meta(conn, f"{prefix}_high_water", f"{prefix}_progress")
self._execute_write(_do)
- def fts_rebuild_step(self) -> bool:
+ def fts_rebuild_step(self, progress: Optional[_BackfillProgress] = None) -> bool:
"""Backfill one chunk (``_fts_chunk_rows`` messages) of the deferred FTS rebuild; True while
work remains. Chunks are claimed atomically inside the write transaction, so concurrent
processes interleave instead of duplicating rows. A failed chunk raises (nothing committed;
- the caller decides whether to retry)."""
+ the caller decides whether to retry). *progress* gains the messages the chunk covered."""
if not self._fts_enabled:
return False
inserts = [self._CHUNK_INSERT_SQL.format(table="messages_fts", extra="")]
if self._trigram_available:
inserts.append(self._TRIGRAM_CHUNK_INSERT_SQL)
- return self._rebuild_step("fts_rebuild", inserts, finish=self._fts_rebuild_finish, finish_when_empty=True)
+ return self._rebuild_step("fts_rebuild", inserts, finish=self._fts_rebuild_finish, finish_when_empty=True,
+ progress=progress)
- def fts_cjk_rebuild_step(self) -> bool:
+ def fts_cjk_rebuild_step(self, progress: Optional[_BackfillProgress] = None) -> bool:
"""Backfill one chunk of the CJK index. True while work remains."""
if not self._fts_enabled or not self._fts_cjk_loaded:
return False
insert = self._CHUNK_INSERT_SQL.format(table="messages_fts_cjk", extra=" AND role <> 'tool'")
- return self._rebuild_step("fts_cjk_rebuild", [insert], finish=self._fts_cjk_rebuild_finish)
+ return self._rebuild_step("fts_cjk_rebuild", [insert], finish=self._fts_cjk_rebuild_finish, progress=progress)
- def _rebuild_step(self, prefix: str, insert_sqls: list[str], *, finish, finish_when_empty: bool = False) -> bool:
+ def _rebuild_step(self, prefix: str, insert_sqls: list[str], *, finish, finish_when_empty: bool = False,
+ progress: Optional[_BackfillProgress] = None) -> bool:
"""Shared chunk engine for the base and CJK deferred backfills. ``finish_when_empty``
finalizes a high_water <= 0 marker (empty messages table) instead of leaving it pending."""
high_water_raw = self.get_meta(f"{prefix}_high_water")
@@ -385,22 +444,29 @@ class SessionSearchMixin:
# the claim: two workers cannot read the same value.
row = _meta_row(conn, f"{prefix}_progress")
if row is None:
- return False # finished (or cleared) by another process
- progress = int(row[0])
- if progress >= high_water:
- return False
+ return False, 0 # finished (or cleared) by another process
+ done = int(row[0])
+ if done >= high_water:
+ return False, 0
# The window is the next `chunk` messages, not `chunk` ids: an id gap (pruned
# sessions) neither shrinks a chunk nor lets the next one span a million dense ids.
nth = conn.execute("SELECT id FROM messages WHERE id > ? AND id <= ? ORDER BY id LIMIT 1 OFFSET ?",
- (progress, high_water, chunk - 1)).fetchone()
+ (done, high_water, chunk - 1)).fetchone()
upper = int(nth[0]) if nth is not None else high_water
+ covered = chunk if nth is not None else 0
+ if nth is None and progress is not None: # the last, short window (< chunk rows)
+ covered = conn.execute("SELECT COUNT(*) FROM messages WHERE id > ? AND id <= ?",
+ (done, upper)).fetchone()[0]
for sql in insert_sqls:
- conn.execute(sql, (progress, upper))
+ conn.execute(sql, (done, upper))
# Progress lands in the same transaction as its rows (crash-atomic).
conn.execute("UPDATE state_meta SET value = ? WHERE key = ?", (str(upper), f"{prefix}_progress"))
- return upper < high_water
+ return upper < high_water, covered
- more = self._execute_write(_do)
+ more, covered = self._execute_write(_do)
+ if progress is not None:
+ # Counted after the commit: _execute_write may replay _do on contention.
+ progress.indexed += covered
if more is False:
status = self._rebuild_status(prefix)
if (finish_when_empty and high_water <= 0) or (
@@ -489,7 +555,7 @@ class SessionSearchMixin:
table_count = progress.table_count or len(trash)
# COUNT before publishing the table: a failed COUNT must leave progress on the previous
# table so the retry runs this start step again instead of inheriting its totals.
- total = int(conn.execute(f"SELECT COUNT(*) FROM {tbl} {where}", params).fetchone()[0])
+ total = int(conn.execute(f"SELECT COUNT(*) FROM {tbl} {where}", params).fetchone()[0]) if progress.counted else 0
progress.table_count, progress.total, progress.drained = table_count, total, 0
progress.table_index = max(1, table_count - len(trash) + 1)
self._fts_chunk_rows = self._FTS_REBUILD_CHUNK_ROWS
@@ -663,22 +729,30 @@ class SessionSearchMixin:
raise sqlite3.OperationalError(failure_message)
self._conn.commit()
- def _backfill_progress_info(self, phase: str = "backfill") -> dict[str, Any]:
- st = self.fts_rebuild_status() or self.fts_cjk_rebuild_status()
- return {"phase": phase, "percent": st["percent"] if st else 100,
- "indexed": st["indexed"] if st else 0, "total": st["total"] if st else 0}
+ def _backfill_progress(self, phase: str, prefix: str) -> Optional[_BackfillProgress]:
+ """Messages at or below *prefix*'s high water, and how many of them the resume marker already
+ covers (one scan per pass); None when the pass has nothing pending."""
+ st = self._rebuild_status(prefix)
+ if st is None:
+ return None
+ with self._read_ctx() as conn:
+ total, done = conn.execute("SELECT COUNT(*), COALESCE(SUM(id <= ?), 0) FROM messages WHERE id <= ?",
+ (st["indexed"], st["total"])).fetchone()
+ return _BackfillProgress(phase=phase, total=int(total), indexed=int(done))
# A chunk that fails this many times in a row ends the run (each lock failure already waited out
# _execute_write's patience); a full disk or I/O error never heals by retrying.
_OPTIMIZE_CHUNK_ATTEMPTS = 3
_VACUUM_TICK_S = 1.0
+ # How often the side thread re-scans for another process holding state.db (~15 ms on Linux).
+ _HOLDER_SCAN_INTERVAL_S = 2.0
- def _optimize_drive(self, step: Callable[[], bool], emit: Callable[[], None], *, paced: bool) -> None:
- """Run *step* to completion, emitting after every chunk. Back to back (no other writer: see
- the chunk engine note in hermes_state_fts) unless *paced*, which keeps the duty cycle for a
- holder ``--force`` ran past. Chunk size adapts to its measured cost; a step may restart it
- (teardown on each new table)."""
- target = self._FTS_REBUILD_PACED_TARGET_S if paced else self._FTS_REBUILD_CHUNK_TARGET_S
+ def _optimize_drive(self, step: Callable[[], bool], info: Optional[Callable[[], dict[str, Any]]],
+ emit: Callable[[dict[str, Any]], None], watch: _HolderWatch) -> None:
+ """Run *step* to completion, emitting ``info()`` (plus ``paced``) after every chunk. Back to
+ back while no other process holds state.db (see the chunk engine note in hermes_state_fts);
+ while *watch* sees a holder, the duty cycle (short chunks + a pause) keeps its writes moving.
+ Chunk size adapts to its measured cost; a step may restart it (teardown on each new table)."""
self._fts_chunk_rows = self._FTS_REBUILD_CHUNK_ROWS
failures = 0
while True:
@@ -695,8 +769,10 @@ class SessionSearchMixin:
failures = 0
if not more:
return
- cost = time.monotonic() - t0
- emit()
+ cost, paced = time.monotonic() - t0, watch.held
+ if info is not None:
+ emit({**info(), "paced": paced})
+ target = self._FTS_REBUILD_PACED_TARGET_S if paced else self._FTS_REBUILD_CHUNK_TARGET_S
self._fts_chunk_rows = self._next_chunk_rows(self._fts_chunk_rows, cost, target)
if paced:
time.sleep(max(self._FTS_REBUILD_MIN_PAUSE, cost * self._FTS_REBUILD_DUTY_FACTOR))
@@ -772,14 +848,14 @@ class SessionSearchMixin:
def optimize_fts_storage(
self, *, progress_cb: Optional[Callable[[dict[str, Any]], None]] = None, vacuum: bool = True,
- yield_to_writers: bool = False,
) -> dict[str, Any]:
"""Repair an older FTS layout into the current v23 shape, foreground and to completion:
legacy-v22 inline -> external-content, or a v23 ``messages_fts_trigram`` that still stores
``tool_calls``. Re-running resumes; a chunk that keeps failing raises (nothing is lost).
- ``progress_cb`` receives {"phase", "percent", "indexed", "total"} after every chunk (plus
- :meth:`_TeardownProgress.info` / :meth:`_VacuumProgress.info` fields) and every second of the
- VACUUM. ``yield_to_writers`` (``--force`` past a live holder) paces chunks so its writes get
+ ``progress_cb`` receives {"phase", "percent", "indexed", "total", "paced"} after every chunk
+ (``backfill``/``backfill_cjk`` count messages; plus :meth:`_TeardownProgress.info` fields)
+ and every second of the VACUUM (:meth:`_VacuumProgress.info`). Whenever another process holds
+ state.db (re-scanned every ``_HOLDER_SCAN_INTERVAL_S``) chunks are paced so its writes get
through. A missing trigram tokenizer is not fatal (CJK falls back to LIKE)."""
if not self._fts_enabled:
return {"ok": False, "reason": "fts5_unavailable"}
@@ -805,14 +881,18 @@ class SessionSearchMixin:
self._ensure_cjk_schema_committed()
emit = progress_cb or (lambda _info: None)
- teardown = _TeardownProgress()
- # Phase 1: base backfill; 1b: CJK-bigram backfill (own marker pair).
- emit(self._backfill_progress_info())
- for step in (self.fts_rebuild_step, self.fts_cjk_rebuild_step):
- self._optimize_drive(step, lambda: emit(self._backfill_progress_info()), paced=yield_to_writers)
- # Phase 2: tear down the demoted legacy shadow tables in chunks.
- self._optimize_drive(lambda: self._fts_teardown_trash_step(teardown), lambda: emit(teardown.info()),
- paced=yield_to_writers)
+ teardown = _TeardownProgress(counted=progress_cb is not None)
+ passes = (("backfill", "fts_rebuild", self.fts_rebuild_step), ("backfill_cjk", "fts_cjk_rebuild",
+ self.fts_cjk_rebuild_step))
+ with _HolderWatch(self._foreign_state_db_holders, self._HOLDER_SCAN_INTERVAL_S) as watch:
+ # Phase 1: base backfill; 1b: CJK-bigram backfill (own marker pair, own progress line).
+ for phase, prefix, step in passes:
+ bf = self._backfill_progress(phase, prefix) if progress_cb else None
+ if bf is not None:
+ emit({**bf.info(), "paced": watch.held})
+ self._optimize_drive(partial(step, bf) if bf else step, bf.info if bf else None, emit, watch)
+ # Phase 2: tear down the demoted legacy shadow tables in chunks.
+ self._optimize_drive(partial(self._fts_teardown_trash_step, teardown), teardown.info, emit, watch)
with self._read_ctx() as conn:
still_pending = _meta_row(conn, "fts_rebuild_high_water") is not None
still_trash = self._has_fts_trash(conn)
@@ -831,7 +911,7 @@ class SessionSearchMixin:
# A concurrent process changed state since the pre-vacuum check; a re-run can still settle.
logger.warning("FTS storage optimization settle refused (%s)", refusal)
return {"ok": False, "reason": refusal, "vacuumed": vacuum_ok}
- emit(self._backfill_progress_info("done"))
+ emit({"phase": "done", "percent": 100, "indexed": 0, "total": 0})
logger.info("FTS storage optimization complete (layout v%d).", FTS_STORAGE_VERSION)
return {"ok": True, "vacuumed": vacuum_ok}
diff --git a/tests/hermes_state/test_fts_optimize_progress.py b/tests/hermes_state/test_fts_optimize_progress.py
index 33137e7db8..763d85c869 100644
--- a/tests/hermes_state/test_fts_optimize_progress.py
+++ b/tests/hermes_state/test_fts_optimize_progress.py
@@ -6,11 +6,13 @@ import itertools
import sqlite3
import threading
import time
+from argparse import Namespace
import pytest
+from hermes_cli.sessions_cmd import _cmd_optimize_storage, _optimize_progress_line
from hermes_state import SessionDB
-from hermes_state_common import LEGACY_FTS_SQL, SCHEMA_SQL
+from hermes_state_common import LEGACY_FTS_SQL, LEGACY_FTS_TRIGRAM_SQL, SCHEMA_SQL
GAP_AFTER, GAP_IDS = 600, 100_000 # a pruned store: ids jump after the first 600 messages
@@ -19,7 +21,7 @@ def _legacy_inline_store(db_path, n=1200):
ids = [i + 1 + (GAP_IDS if i >= GAP_AFTER else 0) for i in range(n)]
raw = sqlite3.connect(str(db_path))
try:
- raw.executescript(SCHEMA_SQL + LEGACY_FTS_SQL)
+ raw.executescript(SCHEMA_SQL + LEGACY_FTS_SQL + LEGACY_FTS_TRIGRAM_SQL)
raw.execute("INSERT INTO sessions (id, source, started_at) VALUES ('s1', 'cli', ?)", (time.time(),))
raw.executemany(
"INSERT INTO messages (id, session_id, timestamp, role, content) VALUES (?, 's1', ?, 'user', ?)",
@@ -68,6 +70,10 @@ def test_progress_reports_teardown_rows_and_vacuum_liveness(legacy_db, monkeypat
# The ticker is stopped and joined on success and on failure: nothing can report after return.
assert not [t for t in threading.enumerate() if t.name == "optimize-vacuum-ticker"]
assert events[-1]["phase"] == "done"
+ # Backfill counts messages, not ids: after the first 500-row chunk the 100k-id gap still reads 500/1,200.
+ backfill = [e for e in events if e["phase"] == "backfill"]
+ assert {e["total"] for e in backfill} == {1200} and backfill[0]["indexed"] == 0
+ assert (backfill[1]["indexed"], backfill[1]["percent"]) == (500, 41)
vacuum = [e for e in events if e["phase"] == "vacuum"]
assert vacuum[0]["size_bytes"] >= vacuum[0]["estimated_bytes"] > 0
teardown = [e for e in events if e["phase"] == "teardown"]
@@ -76,7 +82,9 @@ def test_progress_reports_teardown_rows_and_vacuum_liveness(legacy_db, monkeypat
assert docsize[0]["total"] == 1200 and docsize[0]["indexed"] == 0
assert docsize[-1]["indexed"] == 1200 and docsize[-1]["percent"] == 100
count = teardown[0]["table_count"]
- assert count >= 2 and {e["table_index"] for e in teardown} <= set(range(1, count + 1))
+ # Both legacy indexes (base + trigram) are reclaimed: five shadow tables each.
+ assert count == 10 and {e["table_index"] for e in teardown} <= set(range(1, count + 1))
+ assert any(e["table"].endswith("messages_fts_trigram_data") and e["total"] > 0 for e in teardown)
assert legacy_db.search_messages("quokka")
@@ -87,8 +95,8 @@ def test_foreground_optimize_never_sleeps_and_chunks_by_rows_across_id_gaps(lega
monkeypatch.setattr(SessionDB, "_FTS_REBUILD_CHUNK_MAX_ROWS", 500)
markers, real_step = [0], legacy_db.fts_rebuild_step
- def observed_step():
- more = real_step()
+ def observed_step(*args):
+ more = real_step(*args)
status = legacy_db.fts_rebuild_status()
markers.append(status["indexed"] if status else GAP_IDS + 1200)
return more
@@ -103,21 +111,57 @@ def test_foreground_optimize_never_sleeps_and_chunks_by_rows_across_id_gaps(lega
assert windows == [500, 500, 200]
-def test_chunk_that_keeps_failing_ends_the_run_and_a_rerun_resumes(legacy_db, monkeypatch):
+def test_chunk_that_keeps_failing_ends_the_run_and_a_rerun_resumes(legacy_db, monkeypatch, capsys):
monkeypatch.setattr(time, "sleep", lambda _s: None) # the retry backoff
calls, real_step = [], legacy_db.fts_rebuild_step
- def disk_fills_during_backfill():
+ def disk_fills_during_backfill(*args):
if not calls:
pages = legacy_db._conn.execute("PRAGMA page_count").fetchone()[0]
legacy_db._conn.execute(f"PRAGMA max_page_count = {pages + 5}")
calls.append(1)
assert len(calls) < 50, "optimize kept retrying a chunk that can never succeed"
- return real_step()
+ return real_step(*args)
legacy_db.fts_rebuild_step = disk_fills_during_backfill
with pytest.raises(sqlite3.OperationalError, match="full"):
legacy_db.optimize_fts_storage(vacuum=False)
+ # The CLI reports the failure and exits non-zero (main() reads None as success).
+ calls.clear()
+ assert _cmd_optimize_storage(legacy_db, Namespace(yes=True, no_vacuum=True, force=False)) == 1
+ assert "Error: optimization failed: database or disk is full" in capsys.readouterr().out
legacy_db._conn.execute("PRAGMA max_page_count = 1073741823")
assert legacy_db.optimize_fts_storage(vacuum=False)["ok"] is True
assert len(legacy_db.search_messages("quokka", limit=2000)) == 1200
+
+
+def test_writer_that_opens_the_store_mid_run_gets_the_paced_cycle(legacy_db, monkeypatch):
+ slept, scans, events, writer_opened = [], [], [], threading.Event()
+ monkeypatch.setattr(time, "sleep", slept.append)
+ monkeypatch.setattr(SessionDB, "_HOLDER_SCAN_INTERVAL_S", 0.01)
+ monkeypatch.setattr(SessionDB, "_FTS_REBUILD_CHUNK_MAX_ROWS", 100)
+
+ def holder_scan():
+ scans.append(1)
+ if len(scans) == 1:
+ return [] # quiet when the run starts: no pacing
+ writer_opened.set()
+ return [(4242, str(legacy_db.db_path))]
+
+ monkeypatch.setattr(legacy_db, "_foreign_state_db_holders", holder_scan)
+ real_step = legacy_db.fts_rebuild_step
+
+ def step_then_writer_opens(*args):
+ more = real_step(*args)
+ assert writer_opened.wait(10) # e.g. Desktop launched after the first chunk
+ return more
+
+ legacy_db.fts_rebuild_step = step_then_writer_opens
+ assert legacy_db.optimize_fts_storage(progress_cb=events.append, vacuum=False)["ok"] is True
+ chunks = [e for e in events if e["phase"] != "done"]
+ assert chunks[0]["paced"] is False and all(e["paced"] for e in chunks[1:])
+ # Every chunk after the writer appeared yields the write lock for at least the duty-cycle floor.
+ teardown = [e for e in chunks if e["phase"] == "teardown"]
+ assert len(slept) >= len(teardown) > 0 and min(slept) >= SessionDB._FTS_REBUILD_MIN_PAUSE
+ assert "paced: another process" in _optimize_progress_line(chunks[-1])
+ assert not [t for t in threading.enumerate() if t.name == "optimize-holder-watch"]
diff --git a/website/docs/user-guide/session-storage-recovery.md b/website/docs/user-guide/session-storage-recovery.md
index ff545834a6..17ee06cdff 100644
--- a/website/docs/user-guide/session-storage-recovery.md
+++ b/website/docs/user-guide/session-storage-recovery.md
@@ -78,7 +78,9 @@ Override with --force if you accept the risk.
`--dry-run` previews are never blocked. `--force` runs anyway — use it only when you know
the listed processes are idle (a reader you started yourself, for example). The same check
-runs when you type `sessions optimize` in the Desktop console.
+runs when you type `sessions optimize` in the Desktop console. `optimize-storage` keeps watching
+while it runs: whenever another process holds the database (under `--force`, or one that opens it
+mid-run) it pauses between chunks so that process's writes get through, which makes it much slower.
`hermes sessions prune` is the exception while the gateway is running: the gateway owns
`state.db`, so prune shows its preview from a read-only view and the gateway itself deletes
From 9a2fa5e406f9e9ca8c3716995ee7d4a778e5b50c Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 17:48:56 -0700
Subject: [PATCH 012/114] fix(sessions): optimize-storage counts backfill rows
from an index; no VACUUM over a live writer
From an independent re-review of the mid-run pacing change:
- The pre-backfill message count walked the messages table by rowid
range: 73 s cold on a 1.97M-message store with nothing printed, the
symptom this PR fixes. +id lets the planner count from a covering
index (1.9 s cold).
- A writer that appeared mid-run was still locked out by the
minutes-long VACUUM. VACUUM is skipped while another process holds
the store (vacuumed=False, vacuum_skipped=in_use) with a message to
re-run when it is closed.
- The "Not enough free disk" refusal exits 1, like the other failures.
---
hermes_cli/sessions_cmd.py | 7 +++++--
hermes_state_search.py | 14 ++++++++++----
tests/hermes_state/test_fts_optimize_progress.py | 5 ++++-
3 files changed, 19 insertions(+), 7 deletions(-)
diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py
index 4881375d60..152d636bd0 100644
--- a/hermes_cli/sessions_cmd.py
+++ b/hermes_cli/sessions_cmd.py
@@ -1055,7 +1055,7 @@ def _cmd_optimize_storage(db, args):
if free_bytes < need_bytes:
print("\n⚠ Not enough free disk to complete safely. Free up space, or run with --no-vacuum "
"(rebuilds the index but doesn't reclaim space until a later VACUUM).")
- return
+ return 1
if before_mb > 500:
print(" This may take a while on a large database. It runs in the foreground with progress below; "
"safe to Ctrl-C and re-run (it resumes).")
@@ -1087,7 +1087,10 @@ def _cmd_optimize_storage(db, args):
return 1
print("✓ Search index optimized.")
_print_size_change(db, before_mb, prefix=" ")
- if result.get("vacuumed") is False:
+ if result.get("vacuum_skipped") == "in_use":
+ print(" (VACUUM skipped: another process is using the database. Re-run `hermes sessions optimize` "
+ "with it closed to reclaim freed space.)")
+ elif result.get("vacuumed") is False:
print(" (VACUUM was skipped or failed — run `hermes sessions optimize` later to reclaim freed space.)")
diff --git a/hermes_state_search.py b/hermes_state_search.py
index bfc75aecf7..e459688104 100644
--- a/hermes_state_search.py
+++ b/hermes_state_search.py
@@ -736,7 +736,9 @@ class SessionSearchMixin:
if st is None:
return None
with self._read_ctx() as conn:
- total, done = conn.execute("SELECT COUNT(*), COALESCE(SUM(id <= ?), 0) FROM messages WHERE id <= ?",
+ # ``+id`` keeps the planner off the rowid range: counting from a covering index reads
+ # far fewer pages (1.97M messages, cold: 73 s on the table b-tree, 1.9 s here).
+ total, done = conn.execute("SELECT COUNT(*), COALESCE(SUM(id <= ?), 0) FROM messages WHERE +id <= ?",
(st["indexed"], st["total"])).fetchone()
return _BackfillProgress(phase=phase, total=int(total), indexed=int(done))
@@ -903,8 +905,12 @@ class SessionSearchMixin:
reason, still_pending, still_trash, empty_index)
return {"ok": False, "reason": reason, "vacuumed": None}
- vacuum_ok = None
- if vacuum:
+ vacuum_ok, vacuum_skipped = None, False
+ if vacuum and self._foreign_state_db_holders():
+ # VACUUM holds the write lock for minutes; a live writer would time out mid-append.
+ logger.info("Skipping VACUUM after FTS optimize: another process is using %s", self.db_path)
+ vacuum_ok, vacuum_skipped = False, True
+ elif vacuum:
vacuum_ok = self._optimize_vacuum(progress_cb)
refusal = self._execute_write(self._optimize_settle)
if refusal is not None:
@@ -913,7 +919,7 @@ class SessionSearchMixin:
return {"ok": False, "reason": refusal, "vacuumed": vacuum_ok}
emit({"phase": "done", "percent": 100, "indexed": 0, "total": 0})
logger.info("FTS storage optimization complete (layout v%d).", FTS_STORAGE_VERSION)
- return {"ok": True, "vacuumed": vacuum_ok}
+ return {"ok": True, "vacuumed": vacuum_ok, **({"vacuum_skipped": "in_use"} if vacuum_skipped else {})}
# ── Read views ─────────────────────────────────────────────────────────
diff --git a/tests/hermes_state/test_fts_optimize_progress.py b/tests/hermes_state/test_fts_optimize_progress.py
index 763d85c869..6e64201af7 100644
--- a/tests/hermes_state/test_fts_optimize_progress.py
+++ b/tests/hermes_state/test_fts_optimize_progress.py
@@ -157,8 +157,11 @@ def test_writer_that_opens_the_store_mid_run_gets_the_paced_cycle(legacy_db, mon
return more
legacy_db.fts_rebuild_step = step_then_writer_opens
- assert legacy_db.optimize_fts_storage(progress_cb=events.append, vacuum=False)["ok"] is True
+ result = legacy_db.optimize_fts_storage(progress_cb=events.append, vacuum=True)
+ # The writer is still there at the end: no minutes-long VACUUM lock over its appends.
+ assert result == {"ok": True, "vacuumed": False, "vacuum_skipped": "in_use"}
chunks = [e for e in events if e["phase"] != "done"]
+ assert not [e for e in chunks if e["phase"] == "vacuum"]
assert chunks[0]["paced"] is False and all(e["paced"] for e in chunks[1:])
# Every chunk after the writer appeared yields the write lock for at least the duty-cycle floor.
teardown = [e for e in chunks if e["phase"] == "teardown"]
From cffe11a42b16cda54f285ee8af59b7c3bace5444 Mon Sep 17 00:00:00 2001
From: Cary Palmer
Date: Tue, 29 Sep 2026 16:33:14 -0500
Subject: [PATCH 013/114] fix(desktop): keep background processes alive on
plain interrupt; reap them on regenerate
cancelRun() (Stop, and "send now" on a queued message while busy) called
resetSessionBackground(), which issues process.kill for every running
background process in the session. A plain interrupt keeps the timeline,
and the gateway's session.interrupt deliberately leaves
terminal(background=true) processes running, so the renderer was killing
work the backend contract says must survive (e.g. a dev server started
several turns earlier).
- cancelRun: drop resetSessionBackground().
- reloadFromMessage (regenerate): it truncates history through
prompt.submit just like restore/edit, but never reset background state,
so processes spawned by the discarded turns stayed live. It now clears
todos, resets background (killing running processes) and clears preview
artifacts before the re-run, matching restoreToMessage / editMessage.
Tests (use-prompt-actions/index.test.tsx): cancelRun sends
session.interrupt and no process.kill and the running row survives;
reloadFromMessage and restoreToMessage kill the running process and clear
the rows. The cancel and reload cases fail on main and pass with this change.
Co-Authored-By: Claude Opus 5.5
---
.../hooks/use-prompt-actions/index.test.tsx | 105 +++++++++++++++++-
.../session/hooks/use-prompt-actions/index.ts | 13 ++-
2 files changed, 116 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
index 58e5503079..df297c8e4a 100644
--- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
+++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
@@ -11,8 +11,9 @@ import { createClientSessionState } from '@/lib/chat-runtime'
import { $compactingSessions, setSessionCompacting } from '@/store/compaction'
import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
import { $queuedPromptsBySession, getQueuedPrompts } from '@/store/composer-queue'
+import { $backgroundStatusBySession, reconcileBackgroundProcesses } from '@/store/composer-status'
import { $confirmRequest, settleConfirm } from '@/store/confirm'
-import { requestGatewayForAgent } from '@/store/gateway'
+import { $gateway, requestGatewayForAgent } from '@/store/gateway'
import { $goalsBySession, setSessionGoal } from '@/store/goals'
import { $hudMode } from '@/store/hud'
import { $notifications, clearNotifications } from '@/store/notifications'
@@ -5093,6 +5094,108 @@ describe('usePromptActions cancelRun', () => {
})
})
+// Plain interrupts must not kill background work; rewinds must. cancelRun is
+// Stop and "send now" on a queued message while busy — the timeline survives,
+// and the gateway's session.interrupt leaves terminal(background=true) running.
+// reload / restore / edit truncate history, so processes from the discarded
+// turns get reaped.
+describe('usePromptActions background processes: interrupt vs rewind', () => {
+ const seed = [
+ { id: 'u1', parts: [textPart('first')], role: 'user' as const, timestamp: 0 },
+ { id: 'a1', parts: [textPart('reply')], role: 'assistant' as const, timestamp: 1 }
+ ]
+
+ let gatewayCalls: Array<{ method: string; params?: Record }>
+ // resetSessionBackground remembers dismissed ids per session at module
+ // level, so each test seeds a fresh process id.
+ let procSeq = 0
+ let procId = ''
+
+ beforeEach(() => {
+ procId = `proc_live_${++procSeq}`
+ $busy.set(false)
+ $backgroundStatusBySession.set({})
+ setMessages(seed as never)
+ gatewayCalls = []
+ $gateway.set({
+ request: async (method: string, params?: Record) => {
+ gatewayCalls.push({ method, params })
+
+ return {} as never
+ }
+ } as never)
+ reconcileBackgroundProcesses(RUNTIME_SESSION_ID, [
+ { command: 'npm run dev', session_id: procId, status: 'running' }
+ ] as never)
+ })
+
+ afterEach(() => {
+ cleanup()
+ clearNotifications()
+ setMessages([])
+ $busy.set(false)
+ $backgroundStatusBySession.set({})
+ $gateway.set(null)
+ vi.restoreAllMocks()
+ })
+
+ const killed = () => gatewayCalls.some(call => call.method === 'process.kill' && call.params?.process_id === procId)
+
+ async function mount(requestGateway: ReturnType) {
+ let handle: HarnessHandle | null = null
+ await actRender(
+ (handle = h)}
+ refreshSessions={async () => undefined}
+ requestGateway={requestGateway as never}
+ seedMessages={seed}
+ />
+ )
+
+ return handle!
+ }
+
+ it('cancelRun interrupts the turn but leaves running background processes alone', async () => {
+ const requestGateway = vi.fn(async () => ({}) as never)
+ const handle = await mount(requestGateway)
+
+ await handle.cancelRun()
+
+ expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: RUNTIME_SESSION_ID })
+ expect(requestGateway).not.toHaveBeenCalledWith('process.kill', expect.anything())
+ expect(killed()).toBe(false)
+
+ const rows = $backgroundStatusBySession.get()[RUNTIME_SESSION_ID] ?? []
+
+ expect(rows.map(row => [row.id, row.state])).toEqual([[procId, 'running']])
+ })
+
+ it('reloadFromMessage (regenerate) kills processes from the truncated turns', async () => {
+ const requestGateway = vi.fn(async () => ({}) as never)
+ const handle = await mount(requestGateway)
+
+ await handle.reloadFromMessage('u1')
+
+ expect(requestGateway).toHaveBeenCalledWith(
+ 'prompt.submit',
+ expect.objectContaining({ confirm_truncate: true, truncate_before_message_id: 'u1' }),
+ expect.anything()
+ )
+ expect(killed()).toBe(true)
+ expect($backgroundStatusBySession.get()[RUNTIME_SESSION_ID] ?? []).toEqual([])
+ })
+
+ it('restoreToMessage still kills processes from the discarded turns', async () => {
+ const requestGateway = vi.fn(async () => ({}) as never)
+ const handle = await mount(requestGateway)
+
+ await handle.restoreToMessage('u1')
+
+ expect(killed()).toBe(true)
+ expect($backgroundStatusBySession.get()[RUNTIME_SESSION_ID] ?? []).toEqual([])
+ })
+})
+
describe('usePromptActions eager attachment upload (drop-time)', () => {
afterEach(() => {
cleanup()
diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
index a38707fd49..85b4366f85 100644
--- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
+++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
@@ -801,7 +801,11 @@ export function usePromptActions({
clearSessionTodos(sessionId)
clearSessionSubagents(sessionId)
- resetSessionBackground(sessionId)
+ // No resetSessionBackground() here. A plain interrupt (Stop, or "send now"
+ // on a queued message while busy) keeps the timeline, and the gateway's
+ // session.interrupt deliberately leaves terminal(background=true) processes
+ // running. Killing them belongs only to the rewind paths (reload / restore /
+ // edit), where the spawning turns are discarded.
setSessionDraftingTool(sessionId, '')
// Auto-compaction sets a per-session flag that only clears on message.start
// / message.complete / error. A hung compaction emits none of those, so the
@@ -1106,6 +1110,13 @@ export function usePromptActions({
return
}
+ // Regenerate truncates history (prompt.submit truncate_before_*) just like
+ // restore/edit, so the discarded turns' todos and background processes
+ // belong to an abandoned timeline — drop them before the re-run.
+ clearSessionTodos(sessionId)
+ resetSessionBackground(sessionId)
+ clearPreviewArtifacts(sessionId)
+
clearNotifications()
updateSessionState(sessionId, state => applyReloadOptimistic(state, plan))
From 3ae67dc3d6fbb723df7fefff1e083da6c8918c3d Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 18:10:59 -0700
Subject: [PATCH 014/114] fix(delegation): stopping a turn no longer kills
background subagents
Ctrl+C, Esc, the Desktop/TUI Stop button, a message that interrupts and
an ACP cancel end the current turn. They also called
async_delegation.interrupt_for_session, so every background subagent the
session had dispatched died with the turn: one Ctrl+C took down a running
/review and both of its sub-reviewers. A background subagent is
independent of the chat that spawned it (Teknium ruling), so a turn stop
now hard-cancels only the turn:
- gateway SessionAuthority.interrupt (Desktop, Ink, classic CLI view,
ACP-through-gateway, messaging interrupts, api_server run cancel)
- tui_gateway session.interrupt (legacy Desktop/TUI stop)
- acp_adapter cancel
The explicit /stop command still ends them (CLI and Ink process.stop,
gateway /stop busy and idle paths), and so does closing or resetting the
session. Reverses the turn-stop half of b71359066cb and 659e55ac71d;
tests/gateway/test_canonical_stop_hard_cancel.py now pins the delegation
surviving a canonical Stop.
---
acp_adapter/server.py | 6 +-----
gateway/session_authority.py | 7 ++-----
tests/gateway/test_canonical_stop_hard_cancel.py | 7 ++++---
tools/AGENTS.md | 10 ++++++----
tui_gateway/session_lifecycle.py | 8 --------
website/docs/user-guide/features/delegation.md | 4 ++--
6 files changed, 15 insertions(+), 27 deletions(-)
diff --git a/acp_adapter/server.py b/acp_adapter/server.py
index 0befc4ca49..56b34bef42 100644
--- a/acp_adapter/server.py
+++ b/acp_adapter/server.py
@@ -653,11 +653,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
state.cancel_event.set()
try:
if state.agent:
- request_hard_interrupt(state.agent)
- # Background delegations are detached from the turn's fan-out; they end with the cancel.
- from tools.async_delegation import interrupt_for_session
- interrupt_for_session(parent_session_id=str(getattr(state.agent, "session_id", "") or ""),
- reason="acp_cancel")
+ request_hard_interrupt(state.agent) # this turn only: background delegations keep running
except Exception:
logger.debug("Failed to interrupt ACP session %s", session_id, exc_info=True)
logger.info("Cancelled session %s", session_id)
diff --git a/gateway/session_authority.py b/gateway/session_authority.py
index ea19e1ede2..3dd8ef66f7 100644
--- a/gateway/session_authority.py
+++ b/gateway/session_authority.py
@@ -896,14 +896,11 @@ class SessionAuthority:
self.pending_stops[ref.session_id] = generation
if agent is not None:
self.delivered_stops[ref.session_id] = generation
- # Main's Stop: hard-cancel (aborts compression too) and end the session's own background
- # delegations (spawned by this session only: a viewer must not kill other gateway work).
+ # Stop hard-cancels this turn (aborts compression too). Background delegations are not the
+ # turn: they keep running and report back; `/stop` and session close/reset end them.
from agent.interrupt_compat import request_hard_interrupt
- from tools.async_delegation import interrupt_for_session
if agent is not None:
request_hard_interrupt(agent)
- for parent in dict.fromkeys((ref.session_id, self.physical_target(ref))):
- interrupt_for_session(session_key=live.route, parent_session_id=parent, reason='user_stop')
return self._handle(ref)
def _classic_turn_holds(self, route):
diff --git a/tests/gateway/test_canonical_stop_hard_cancel.py b/tests/gateway/test_canonical_stop_hard_cancel.py
index 4a45478342..981358f777 100644
--- a/tests/gateway/test_canonical_stop_hard_cancel.py
+++ b/tests/gateway/test_canonical_stop_hard_cancel.py
@@ -1,4 +1,5 @@
-"""Canonical Stop (Desktop, Ink, classic CLI view, ACP) is main's Stop: hard-cancel + end the session's delegations."""
+"""Canonical Stop (Desktop, Ink, classic CLI view, ACP) hard-cancels the turn and leaves background delegations
+running: a background subagent is independent of the chat that spawned it (Teknium ruling); `/stop` ends them."""
import asyncio
from unittest.mock import MagicMock
@@ -31,7 +32,7 @@ def _delegation(ad, monkeypatch, parent):
@pytest.mark.asyncio
@pytest.mark.parametrize('when', ['adopted', 'before_adoption'])
-async def test_canonical_stop_hard_cancels_and_ends_the_sessions_delegations(tmp_path, monkeypatch, when):
+async def test_canonical_stop_hard_cancels_the_turn_and_keeps_background_delegations(tmp_path, monkeypatch, when):
from gateway import session_finite
from tools import async_delegation as ad
@@ -58,5 +59,5 @@ async def test_canonical_stop_hard_cancels_and_ends_the_sessions_delegations(tmp
stopped.set()
await asyncio.wait_for(authority.sessions['s'].task, 5)
assert agent.stops == ['hard'], f'canonical Stop delivered {agent.stops}; main hard-cancels'
- assert own.called, "the session's background delegation kept running after Stop"
+ assert not own.called, "Stop of the turn ended the session's background delegation"
assert not foreign.called, "Stop ended another session's delegation"
diff --git a/tools/AGENTS.md b/tools/AGENTS.md
index 7aa9bd35cd..3af6842bb2 100644
--- a/tools/AGENTS.md
+++ b/tools/AGENTS.md
@@ -167,10 +167,12 @@ processes are killed at its teardown and their notices are suppressed in the par
the result as `orphaned_processes`, exited-but-never-read notify processes as `unread_completions` (`_ChildRun.account_background_processes`, before `cleanup` kills them). **Child kernels:** a child's `execute_code` kernels are keyed `::child::`, pinned against the `max_session_kernels` LRU cap while the child runs and disposed by `cleanup` (`code_kernel.shutdown_kernels_for_delegated_child`) — never let a finished child's kernel squat the cap. **output_schema:** a miss after the one retry keeps `status: completed` with the raw text in `summary` plus `schema_valid: false` / `schema_errors` / `schema_note` — never discard a child's result. **Durability:** background
delegation is process-local; work that must survive restart uses `cronjob` or
`terminal(background=True, notify_on_complete=True)`. `heartbeat=N` on the same spawn emits a `heartbeat` event (type on the completion queue; delta output only, `HEARTBEAT_MIN_SECONDS` floor, one daemon timer thread for all sessions in `process_registry.py::ProcessRegistry._heartbeat_loop`) — every surface that renders `watch_match` must render `heartbeat` (`process_registry_notifications.py`, `gateway/run.py::_drain_gateway_watch_events`, `tui_gateway/session_notifications.py::_DEDUP_EXTRA_FIELDS`). **Stop fan-out:** a turn's hard interrupt reaches only
-`_active_children`; background units are detached at dispatch, so every stop surface (gateway `/stop` busy AND
-idle paths, TUI `session.interrupt`, ACP `cancel`, CLI `/stop` via `interrupt_all`) calls
-`async_delegation.interrupt_for_session` too. Depth>0 delegations are always synchronous
-(`_model_background_value`), so the stop recurses through the child's own `_active_children` fan-out; an
+`_active_children`; background units are detached at dispatch and survive it on purpose (Teknium ruling: a
+background subagent, `/review` included, is independent of the chat that spawned it). Turn stops — Ctrl+C,
+Esc, the Desktop/TUI Stop button, a message that interrupts, `session.interrupt`, ACP `cancel` — never call
+`async_delegation.interrupt_for_session`. Only the explicit `/stop` command (gateway busy AND idle paths, CLI
+and Desktop process stop) and session close/reset end them. Depth>0 delegations are always synchronous
+(`_model_background_value`), so a stop recurses through the child's own `_active_children` fan-out; an
interrupted entry's `summary` is the child's last real assistant text (`_build_result_entry`). API: `website/docs/developer-guide/subagent-lifecycle-api.md`.
## Tests
diff --git a/tui_gateway/session_lifecycle.py b/tui_gateway/session_lifecycle.py
index bc14a1e8ba..32af14072b 100644
--- a/tui_gateway/session_lifecycle.py
+++ b/tui_gateway/session_lifecycle.py
@@ -815,14 +815,6 @@ def _interrupt_session_turn(
if should_interrupt:
from agent.interrupt_compat import request_hard_interrupt
request_hard_interrupt(session.get("agent"))
- # Background delegations are detached from the turn's interrupt fan-out; a stop ends them too
- # (own UI sid + spawner id only — a viewer tab must not kill gateway work). Each returns as an
- # interrupted completion with its partial output.
- with contextlib.suppress(Exception):
- from tools.async_delegation import interrupt_for_session
- interrupt_for_session(
- origin_ui_session_id=_lifecycle_own_sid(session, sid), reason="user_stop",
- parent_session_id=str(getattr(session.get("agent"), "session_id", "") or ""))
if not run_thread_alive and not compressing:
with session["history_lock"]:
if session.get("running"):
diff --git a/website/docs/user-guide/features/delegation.md b/website/docs/user-guide/features/delegation.md
index 27abe023e6..46a4a4f11b 100644
--- a/website/docs/user-guide/features/delegation.md
+++ b/website/docs/user-guide/features/delegation.md
@@ -212,7 +212,7 @@ The dispatch handle lists each unit (`units[].delegation_id`, `group`, `task_ind
- **Thread pool:** Uses `ThreadPoolExecutor` with the configured concurrency limit as max workers
- **Progress display:** In CLI mode, a tree-view shows tool calls from each subagent in real-time with per-task completion lines. In gateway mode, progress is batched and relayed to the parent's progress callback. CLI and TUI completion notices use task-first titles such as `Subagent Task Completed: Review changes`; multi-task groups use the group name and task count. Unsuccessful or incomplete work gets a corresponding status label. These compact notices do not replace the full results delivered to the parent agent.
- **Result ordering:** Within a unit, results are sorted by task index to match input order regardless of completion order; `TASK i/N` labels index the whole call
-- **Cancellation:** Follow-up messages do not cancel a top-level background batch. `/stop` (gateway `/stop`, CLI `/stop`, the Desktop/TUI Stop button, an ACP cancel) or closing/resetting the owning session ends its background children and every synchronous descendant beneath them; each stopped child still returns as a completion with `status="interrupted"` and its partial output
+- **Cancellation:** Follow-up messages and stopping the current turn (Ctrl+C, Esc, the Desktop/TUI Stop button, an ACP cancel) do not cancel a top-level background batch. The `/stop` command (gateway or CLI) or closing/resetting the owning session ends its background children and every synchronous descendant beneath them; each stopped child still returns as a completion with `status="interrupted"` and its partial output
Synchronous single-task delegation from an orchestrator runs directly without thread pool overhead.
@@ -628,7 +628,7 @@ that must survive an owner restart.
:::warning Background completion durability is not durable execution
Top-level model-facing `delegate_task` calls run in the background automatically where the session supports later delivery. Hermes returns a handle immediately, and the result re-enters the conversation after the child or batch finishes. Orchestrator subagents wait for their workers in the current turn because they must synthesize those results before returning. Stateless request/response endpoints fall back to synchronous execution when they cannot deliver a detached result later.
-- Normal follow-up messages do not cancel background children. `/stop` on any surface (gateway `/stop` — also when the session is idle after the dispatching turn ended — CLI `/stop`, the Desktop/TUI Stop button, an ACP cancel) ends the session's running background delegations, and closing or resetting the owning session does the same.
+- Normal follow-up messages do not cancel background children, and neither does stopping the current turn: Ctrl+C, Esc, the Desktop/TUI Stop button and an ACP cancel end only the turn. The `/stop` command (gateway `/stop` — also when the session is idle after the dispatching turn ended — or CLI `/stop`) ends the session's running background delegations, and closing or resetting the owning session does the same.
- Explicit session close/reset interrupts that session's background children. In the CLI and TUI, the gateway owns the session and the client only views it: when the last viewer exits or runs `/new` (a TUI resume switch counts too), the gateway interrupts that session's background children at once and refuses new ones for it until a viewer opens the session again. A plain detach cancels nothing: closing Desktop, a dropped connection, or exiting while another viewer is still attached leaves the work running. Background processes (`terminal(background=True)`) keep running either way.
- A completion for a CLI or TUI session — a background child's result (including an interrupted one), or a `notify_on_complete` process exit or watch-pattern notice — that reaches the head of the session's queue while no viewer is attached does not start a turn. It is written into the session as a note, and the next turn reads it (for example after `hermes chat --resume`). If a viewer is attached by then, including one that came back after the completion arrived, the completion runs as a turn as before. Messaging chats have no viewers and are unchanged: their completions still start a turn.
- A Hermes process restart does **not** resume a running child. Its attempt becomes `unknown` because Hermes cannot prove which side effects happened.
From 5c1484f759227e02dfcac84bd2f3cf50d08e1902 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 18:10:59 -0700
Subject: [PATCH 015/114] fix(desktop): Stop keeps the rows of background
subagents that outlive the turn
cancelRun dropped every subagent row (clearSessionSubagents), on the
assumption that Stop cancels them all. Stop now ends only the turn, so it
prunes finished rows and keeps running/queued ones; the stopped turn's own
children leave with the next roster snapshot. Without this a surviving
background subagent vanished from the Subagents frame until the next 5 s
poll brought it back.
---
.../app/session/hooks/use-prompt-actions/index.test.tsx | 9 ++++++++-
.../src/app/session/hooks/use-prompt-actions/index.ts | 6 ++++--
apps/desktop/src/store/subagents.ts | 4 ++--
3 files changed, 14 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
index df297c8e4a..ab4848c148 100644
--- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
+++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
@@ -31,6 +31,7 @@ import {
setSessions
} from '@/store/session'
import { dropSessionState, publishSessionState } from '@/store/session-states'
+import { $subagentsBySession, upsertSubagent } from '@/store/subagents'
import { $wakeWord, resetWakeWordState } from '@/store/wake-word'
import { actRender, Harness, type HarnessHandle, RUNTIME_SESSION_ID, sessionInfo } from './index-test-utils'
@@ -5155,12 +5156,18 @@ describe('usePromptActions background processes: interrupt vs rewind', () => {
return handle!
}
- it('cancelRun interrupts the turn but leaves running background processes alone', async () => {
+ it('cancelRun interrupts the turn but leaves running background processes and subagents alone', async () => {
const requestGateway = vi.fn(async () => ({}) as never)
const handle = await mount(requestGateway)
+ upsertSubagent(RUNTIME_SESSION_ID, { goal: 'review', status: 'running', subagent_id: 'bg-review', task_index: 0 })
+ upsertSubagent(RUNTIME_SESSION_ID, { goal: 'old', status: 'completed', subagent_id: 'done-1', task_index: 1 })
await handle.cancelRun()
+ expect(($subagentsBySession.get()[RUNTIME_SESSION_ID] ?? []).map(row => [row.id, row.status])).toEqual([
+ ['bg-review', 'running']
+ ])
+
expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: RUNTIME_SESSION_ID })
expect(requestGateway).not.toHaveBeenCalledWith('process.kill', expect.anything())
expect(killed()).toBe(false)
diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
index 85b4366f85..ed79cc74fb 100644
--- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
+++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
@@ -42,7 +42,7 @@ import {
} from '@/store/session'
import { $sessionStates, isLiveTurnAwaitingEvents, isSessionRemote } from '@/store/session-states'
import { flushStreamDeltas } from '@/store/stream-delta-flush'
-import { clearSessionSubagents } from '@/store/subagents'
+import { pruneFinishedSessionSubagents } from '@/store/subagents'
import { runGatewayRestart } from '@/store/system-actions'
import { clearSessionTodos } from '@/store/todos'
import { setSessionDraftingTool } from '@/store/tool-drafting'
@@ -800,7 +800,9 @@ export function usePromptActions({
})
clearSessionTodos(sessionId)
- clearSessionSubagents(sessionId)
+ // Stop ends this turn only: background subagents keep running and keep their rows; the
+ // stopped turn's own children drop out of the next roster snapshot.
+ pruneFinishedSessionSubagents(sessionId)
// No resetSessionBackground() here. A plain interrupt (Stop, or "send now"
// on a queued message while busy) keeps the timeline, and the gateway's
// session.interrupt deliberately leaves terminal(background=true) processes
diff --git a/apps/desktop/src/store/subagents.ts b/apps/desktop/src/store/subagents.ts
index 9ae2d4c38c..3f3bdf5d55 100644
--- a/apps/desktop/src/store/subagents.ts
+++ b/apps/desktop/src/store/subagents.ts
@@ -336,8 +336,8 @@ export function clearSessionSubagents(sid: string) {
* from the display while background subagents that outlived the spawning turn
* remain visible (and still accept late progress/complete events).
*
- * Distinct from `clearSessionSubagents` (used by the Stop action, which
- * genuinely cancels running subagents and so should drop them all) and from
+ * Also what the Stop action uses: Stop ends the turn, not the background
+ * subagents it spawned. Distinct from `clearSessionSubagents` (drops every row) and from
* `pruneDelegateFallbackSubagents` (which filters by id prefix to remove
* placeholder rows once the real native event arrives).
*/
From 0ebc4a85cd372fa290d10666e60696c23e8c08ff Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 18:14:20 -0700
Subject: [PATCH 016/114] test(desktop): move Stop/rewind background tests to
their own file
index.test.tsx sits over the file-size ratchet; the interrupt-vs-rewind
suite lives in index.background-interrupt.test.tsx instead.
---
.../index.background-interrupt.test.tsx | 141 ++++++++++++++++++
.../hooks/use-prompt-actions/index.test.tsx | 112 +-------------
2 files changed, 142 insertions(+), 111 deletions(-)
create mode 100644 apps/desktop/src/app/session/hooks/use-prompt-actions/index.background-interrupt.test.tsx
diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.background-interrupt.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.background-interrupt.test.tsx
new file mode 100644
index 0000000000..a35c62eaff
--- /dev/null
+++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.background-interrupt.test.tsx
@@ -0,0 +1,141 @@
+import { cleanup } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { getLatestSessionMessages } from '@/hermes'
+import { textPart } from '@/lib/chat-messages'
+import { $backgroundStatusBySession, reconcileBackgroundProcesses } from '@/store/composer-status'
+import { $gateway } from '@/store/gateway'
+import { clearNotifications } from '@/store/notifications'
+import { $busy, setMessages } from '@/store/session'
+import { $subagentsBySession, upsertSubagent } from '@/store/subagents'
+
+import { actRender, Harness, type HarnessHandle, RUNTIME_SESSION_ID } from './index-test-utils'
+import { clearSingleFlightSessionResumeState } from './single-flight-resume'
+
+beforeEach(() => {
+ clearSingleFlightSessionResumeState()
+ vi.mocked(getLatestSessionMessages).mockReset()
+ vi.mocked(getLatestSessionMessages).mockImplementation(async () => ({ messages: [], session_id: 'session' }))
+})
+
+vi.mock('@/hermes', () => ({
+ getLatestSessionMessages: vi.fn(async () => ({ messages: [], session_id: 'session' })),
+ getProfiles: vi.fn(async () => ({ profiles: [] })),
+ getSession: vi.fn(),
+ PROMPT_SUBMIT_REQUEST_TIMEOUT_MS: 1_800_000,
+ setApiRequestProfile: vi.fn(),
+ transcribeAudio: vi.fn()
+}))
+
+vi.mock('@/store/gateway', async importOriginal => ({
+ ...(await importOriginal>()),
+ requestGatewayForAgent: vi.fn()
+}))
+
+// Plain interrupts must not kill background work; rewinds must. cancelRun is
+// Stop and "send now" on a queued message while busy — the timeline survives,
+// and the gateway's session.interrupt leaves terminal(background=true) running.
+// reload / restore / edit truncate history, so processes from the discarded
+// turns get reaped.
+describe('usePromptActions background processes: interrupt vs rewind', () => {
+ const seed = [
+ { id: 'u1', parts: [textPart('first')], role: 'user' as const, timestamp: 0 },
+ { id: 'a1', parts: [textPart('reply')], role: 'assistant' as const, timestamp: 1 }
+ ]
+
+ let gatewayCalls: Array<{ method: string; params?: Record }>
+ // resetSessionBackground remembers dismissed ids per session at module
+ // level, so each test seeds a fresh process id.
+ let procSeq = 0
+ let procId = ''
+
+ beforeEach(() => {
+ procId = `proc_live_${++procSeq}`
+ $busy.set(false)
+ $backgroundStatusBySession.set({})
+ setMessages(seed as never)
+ gatewayCalls = []
+ $gateway.set({
+ request: async (method: string, params?: Record) => {
+ gatewayCalls.push({ method, params })
+
+ return {} as never
+ }
+ } as never)
+ reconcileBackgroundProcesses(RUNTIME_SESSION_ID, [
+ { command: 'npm run dev', session_id: procId, status: 'running' }
+ ] as never)
+ })
+
+ afterEach(() => {
+ cleanup()
+ clearNotifications()
+ setMessages([])
+ $busy.set(false)
+ $backgroundStatusBySession.set({})
+ $gateway.set(null)
+ vi.restoreAllMocks()
+ })
+
+ const killed = () => gatewayCalls.some(call => call.method === 'process.kill' && call.params?.process_id === procId)
+
+ async function mount(requestGateway: ReturnType) {
+ let handle: HarnessHandle | null = null
+ await actRender(
+ (handle = h)}
+ refreshSessions={async () => undefined}
+ requestGateway={requestGateway as never}
+ seedMessages={seed}
+ />
+ )
+
+ return handle!
+ }
+
+ it('cancelRun interrupts the turn but leaves running background processes and subagents alone', async () => {
+ const requestGateway = vi.fn(async () => ({}) as never)
+ const handle = await mount(requestGateway)
+ upsertSubagent(RUNTIME_SESSION_ID, { goal: 'review', status: 'running', subagent_id: 'bg-review', task_index: 0 })
+ upsertSubagent(RUNTIME_SESSION_ID, { goal: 'old', status: 'completed', subagent_id: 'done-1', task_index: 1 })
+
+ await handle.cancelRun()
+
+ expect(($subagentsBySession.get()[RUNTIME_SESSION_ID] ?? []).map(row => [row.id, row.status])).toEqual([
+ ['bg-review', 'running']
+ ])
+
+ expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: RUNTIME_SESSION_ID })
+ expect(requestGateway).not.toHaveBeenCalledWith('process.kill', expect.anything())
+ expect(killed()).toBe(false)
+
+ const rows = $backgroundStatusBySession.get()[RUNTIME_SESSION_ID] ?? []
+
+ expect(rows.map(row => [row.id, row.state])).toEqual([[procId, 'running']])
+ })
+
+ it('reloadFromMessage (regenerate) kills processes from the truncated turns', async () => {
+ const requestGateway = vi.fn(async () => ({}) as never)
+ const handle = await mount(requestGateway)
+
+ await handle.reloadFromMessage('u1')
+
+ expect(requestGateway).toHaveBeenCalledWith(
+ 'prompt.submit',
+ expect.objectContaining({ confirm_truncate: true, truncate_before_message_id: 'u1' }),
+ expect.anything()
+ )
+ expect(killed()).toBe(true)
+ expect($backgroundStatusBySession.get()[RUNTIME_SESSION_ID] ?? []).toEqual([])
+ })
+
+ it('restoreToMessage still kills processes from the discarded turns', async () => {
+ const requestGateway = vi.fn(async () => ({}) as never)
+ const handle = await mount(requestGateway)
+
+ await handle.restoreToMessage('u1')
+
+ expect(killed()).toBe(true)
+ expect($backgroundStatusBySession.get()[RUNTIME_SESSION_ID] ?? []).toEqual([])
+ })
+})
diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
index ab4848c148..58e5503079 100644
--- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
+++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx
@@ -11,9 +11,8 @@ import { createClientSessionState } from '@/lib/chat-runtime'
import { $compactingSessions, setSessionCompacting } from '@/store/compaction'
import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
import { $queuedPromptsBySession, getQueuedPrompts } from '@/store/composer-queue'
-import { $backgroundStatusBySession, reconcileBackgroundProcesses } from '@/store/composer-status'
import { $confirmRequest, settleConfirm } from '@/store/confirm'
-import { $gateway, requestGatewayForAgent } from '@/store/gateway'
+import { requestGatewayForAgent } from '@/store/gateway'
import { $goalsBySession, setSessionGoal } from '@/store/goals'
import { $hudMode } from '@/store/hud'
import { $notifications, clearNotifications } from '@/store/notifications'
@@ -31,7 +30,6 @@ import {
setSessions
} from '@/store/session'
import { dropSessionState, publishSessionState } from '@/store/session-states'
-import { $subagentsBySession, upsertSubagent } from '@/store/subagents'
import { $wakeWord, resetWakeWordState } from '@/store/wake-word'
import { actRender, Harness, type HarnessHandle, RUNTIME_SESSION_ID, sessionInfo } from './index-test-utils'
@@ -5095,114 +5093,6 @@ describe('usePromptActions cancelRun', () => {
})
})
-// Plain interrupts must not kill background work; rewinds must. cancelRun is
-// Stop and "send now" on a queued message while busy — the timeline survives,
-// and the gateway's session.interrupt leaves terminal(background=true) running.
-// reload / restore / edit truncate history, so processes from the discarded
-// turns get reaped.
-describe('usePromptActions background processes: interrupt vs rewind', () => {
- const seed = [
- { id: 'u1', parts: [textPart('first')], role: 'user' as const, timestamp: 0 },
- { id: 'a1', parts: [textPart('reply')], role: 'assistant' as const, timestamp: 1 }
- ]
-
- let gatewayCalls: Array<{ method: string; params?: Record }>
- // resetSessionBackground remembers dismissed ids per session at module
- // level, so each test seeds a fresh process id.
- let procSeq = 0
- let procId = ''
-
- beforeEach(() => {
- procId = `proc_live_${++procSeq}`
- $busy.set(false)
- $backgroundStatusBySession.set({})
- setMessages(seed as never)
- gatewayCalls = []
- $gateway.set({
- request: async (method: string, params?: Record) => {
- gatewayCalls.push({ method, params })
-
- return {} as never
- }
- } as never)
- reconcileBackgroundProcesses(RUNTIME_SESSION_ID, [
- { command: 'npm run dev', session_id: procId, status: 'running' }
- ] as never)
- })
-
- afterEach(() => {
- cleanup()
- clearNotifications()
- setMessages([])
- $busy.set(false)
- $backgroundStatusBySession.set({})
- $gateway.set(null)
- vi.restoreAllMocks()
- })
-
- const killed = () => gatewayCalls.some(call => call.method === 'process.kill' && call.params?.process_id === procId)
-
- async function mount(requestGateway: ReturnType) {
- let handle: HarnessHandle | null = null
- await actRender(
- (handle = h)}
- refreshSessions={async () => undefined}
- requestGateway={requestGateway as never}
- seedMessages={seed}
- />
- )
-
- return handle!
- }
-
- it('cancelRun interrupts the turn but leaves running background processes and subagents alone', async () => {
- const requestGateway = vi.fn(async () => ({}) as never)
- const handle = await mount(requestGateway)
- upsertSubagent(RUNTIME_SESSION_ID, { goal: 'review', status: 'running', subagent_id: 'bg-review', task_index: 0 })
- upsertSubagent(RUNTIME_SESSION_ID, { goal: 'old', status: 'completed', subagent_id: 'done-1', task_index: 1 })
-
- await handle.cancelRun()
-
- expect(($subagentsBySession.get()[RUNTIME_SESSION_ID] ?? []).map(row => [row.id, row.status])).toEqual([
- ['bg-review', 'running']
- ])
-
- expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: RUNTIME_SESSION_ID })
- expect(requestGateway).not.toHaveBeenCalledWith('process.kill', expect.anything())
- expect(killed()).toBe(false)
-
- const rows = $backgroundStatusBySession.get()[RUNTIME_SESSION_ID] ?? []
-
- expect(rows.map(row => [row.id, row.state])).toEqual([[procId, 'running']])
- })
-
- it('reloadFromMessage (regenerate) kills processes from the truncated turns', async () => {
- const requestGateway = vi.fn(async () => ({}) as never)
- const handle = await mount(requestGateway)
-
- await handle.reloadFromMessage('u1')
-
- expect(requestGateway).toHaveBeenCalledWith(
- 'prompt.submit',
- expect.objectContaining({ confirm_truncate: true, truncate_before_message_id: 'u1' }),
- expect.anything()
- )
- expect(killed()).toBe(true)
- expect($backgroundStatusBySession.get()[RUNTIME_SESSION_ID] ?? []).toEqual([])
- })
-
- it('restoreToMessage still kills processes from the discarded turns', async () => {
- const requestGateway = vi.fn(async () => ({}) as never)
- const handle = await mount(requestGateway)
-
- await handle.restoreToMessage('u1')
-
- expect(killed()).toBe(true)
- expect($backgroundStatusBySession.get()[RUNTIME_SESSION_ID] ?? []).toEqual([])
- })
-})
-
describe('usePromptActions eager attachment upload (drop-time)', () => {
afterEach(() => {
cleanup()
From 871900df633d2edebf6afffb55ffca36f3f7048e Mon Sep 17 00:00:00 2001
From: Ben Barclay
Date: Fri, 25 Sep 2026 13:32:09 +1000
Subject: [PATCH 017/114] feat(config): route every user config.yaml read and
write through a ConfigBackend seam
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add hermes_cli/config_backend.py (config-config design §4.1, D10/D11): a
ConfigBackend protocol, the FileBackend (today's behaviour), selection by
HERMES_CONFIG_BACKEND only (default file; 'remote' and unknown values fail
closed with ConfigBackendUnavailable), and path-level helpers
(read_config_doc, read_config_doc_readonly, config_version, config_exists,
write_config_key, write_config_document, supports_file_tooling).
Migrate every direct reader/stat/exists/bypass writer of a user config.yaml
to the helpers (config.py loaders + caches, config_effective, env_loader,
gateway, TUI, CLI watchers, cron, tools, profiles, logging, time). File
tooling (config edit, backups, xAI migration rewrite, raw dashboard editor)
checks supports_file_tooling() first; migrations skip write-back without it.
Add scripts/check_config_yaml_readers.py (CI lint next to the writers gate)
that rejects a direct read of a config path outside the backend.
---
.github/workflows/lint.yml | 5 +
agent/moa_loop.py | 4 +-
agent/skill_utils.py | 16 +-
cli.py | 9 +-
cron/jobs.py | 3 +-
cron/scheduler.py | 3 +-
gateway/config_loader.py | 13 +-
gateway/readiness.py | 6 +-
gateway/run.py | 9 +-
gateway/run_config_loaders.py | 3 +-
hermes_cli/AGENTS.md | 7 +
hermes_cli/auth.py | 3 +-
hermes_cli/backup.py | 4 +-
hermes_cli/cli_config_load.py | 12 +-
hermes_cli/cli_info_mixin.py | 11 +-
hermes_cli/cli_tui_mixin.py | 4 +-
hermes_cli/commands_completion.py | 5 +-
hermes_cli/config.py | 67 +++---
hermes_cli/config_backend.py | 219 ++++++++++++++++++
hermes_cli/config_backups.py | 9 +-
hermes_cli/config_effective.py | 20 +-
hermes_cli/config_read_errors.py | 10 +-
hermes_cli/config_version_stamp.py | 8 +-
hermes_cli/credential_lifecycle.py | 3 +-
hermes_cli/doctor_config.py | 6 +-
hermes_cli/doctor_state.py | 6 +-
hermes_cli/env_loader.py | 15 +-
hermes_cli/gateway_multiplex_mode.py | 6 +-
hermes_cli/kanban_db.py | 2 +-
hermes_cli/main.py | 11 +-
hermes_cli/migrate.py | 3 +-
hermes_cli/model_switch.py | 7 +-
hermes_cli/personality.py | 4 +-
hermes_cli/profile_channels.py | 12 +-
hermes_cli/profile_distribution.py | 2 +-
hermes_cli/profile_memory_config.py | 4 +-
hermes_cli/profiles.py | 31 ++-
hermes_cli/send_cmd.py | 3 +-
hermes_cli/update_channel.py | 4 +-
hermes_cli/update_cmd_config.py | 2 +-
hermes_cli/web_routers/analytics.py | 8 +-
hermes_cli/xai_retirement.py | 11 +-
hermes_logging.py | 7 +-
hermes_time.py | 7 +-
model_tools.py | 5 +-
plugins/platforms/telegram/adapter.py | 3 +-
scripts/check_config_yaml_readers.py | 205 ++++++++++++++++
tests/agent/test_skill_utils.py | 10 +-
tests/hermes_cli/test_config_backend.py | 114 +++++++++
.../hermes_cli/test_config_save_integrity.py | 10 +-
tools/bot_mode_probe.py | 4 +-
tools/terminal_scope.py | 8 +-
tools/website_policy.py | 5 +-
tui_gateway/methods_config.py | 4 +-
tui_gateway/server.py | 13 +-
55 files changed, 808 insertions(+), 177 deletions(-)
create mode 100644 hermes_cli/config_backend.py
create mode 100644 scripts/check_config_yaml_readers.py
create mode 100644 tests/hermes_cli/test_config_backend.py
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 2d41b15410..603bb105e6 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -252,6 +252,11 @@ jobs:
- name: Forbid config.yaml writers that bypass the comment-preserving writer
run: python scripts/check_config_yaml_writers.py
+ # Every read of a user config.yaml goes through hermes_cli.config_backend, so a non-file
+ # config backend (config-config design §4.1) sees every reader.
+ - name: Forbid config.yaml readers that bypass the config backend
+ run: python scripts/check_config_yaml_readers.py
+
# The OS lanes import only files carrying the matching marker, so a test that fakes
# macOS (is_macos -> True, sys.platform -> "darwin") without `platforms("macos")` is green on
# Linux over a faked branch and never runs on macOS (#111866, tests/AGENTS.md § Don't fake the host OS).
diff --git a/agent/moa_loop.py b/agent/moa_loop.py
index 40e62319ea..ff0d140037 100644
--- a/agent/moa_loop.py
+++ b/agent/moa_loop.py
@@ -126,9 +126,9 @@ def _resolve_preset_cached(preset_name: str) -> tuple[dict[str, Any], Any]:
(skips resolve_moa_preset's full validation of the moa block on every create())."""
from hermes_cli.config import get_config_path, load_config
from hermes_cli.moa_config import resolve_moa_preset
- from utils import file_signature
+ from hermes_cli.config_backend import config_version
try:
- cfg_stamp = file_signature(get_config_path().stat())
+ cfg_stamp = config_version(get_config_path())
except OSError:
cfg_stamp = None
moa_raw = load_config().get("moa") or {}
diff --git a/agent/skill_utils.py b/agent/skill_utils.py
index f24faec3d4..a829436603 100644
--- a/agent/skill_utils.py
+++ b/agent/skill_utils.py
@@ -209,23 +209,24 @@ def _raw_config_cache_clear() -> None:
def _config_cache_key(config_path: Path) -> Optional[tuple[str, int, int, int, int]]:
- """``(path, *file_signature)`` identity of config.yaml, or None when unreadable/absent."""
+ """``(path, *backend version)`` identity of config.yaml, or None when unreadable/absent."""
try:
- from utils import file_signature
- return (str(config_path), *file_signature(config_path.stat()))
+ from hermes_cli.config_backend import config_version
+ return (str(config_path), *config_version(config_path))
except OSError:
return None
def _load_raw_config() -> dict[str, Any]:
- """Read config.yaml with an mtime+size keyed cache (no hermes_cli.config import)."""
+ """Read config.yaml with a version-keyed cache (no hermes_cli.config import)."""
from agent.safe_worker_policy import worker_config_snapshot
+ from hermes_cli.config_backend import config_exists, read_config_doc
snapshot = worker_config_snapshot()
if snapshot is not None:
return snapshot
config_path = get_config_path()
- if not config_path.exists():
+ if not config_exists(config_path):
return {}
cache_key = _config_cache_key(config_path)
with _RAW_CONFIG_CACHE_LOCK:
@@ -233,7 +234,7 @@ def _load_raw_config() -> dict[str, Any]:
if cached is not None:
return cached
try:
- parsed = yaml_load(config_path.read_text(encoding="utf-8-sig"))
+ parsed = read_config_doc(config_path)
except Exception as e:
logger.debug("Could not read skill config %s: %s", config_path, e)
return {}
@@ -337,8 +338,9 @@ def _config_str_list(raw) -> list[str]:
def get_external_skills_dirs() -> list[Path]:
"""Validated, deduplicated ``skills.external_dirs`` (existing dirs only). Entries
are ``~``/``${VAR}`` expanded, relative to HERMES_HOME; the local skills dir is skipped."""
+ from hermes_cli.config_backend import config_exists
config_path = get_config_path()
- if not config_path.exists():
+ if not config_exists(config_path):
return []
full_key = _config_cache_key(config_path)
cache_key = full_key
diff --git a/cli.py b/cli.py
index f422411172..450160cc88 100644
--- a/cli.py
+++ b/cli.py
@@ -778,8 +778,8 @@ def save_config_value(key_path: str, value: any) -> bool:
try:
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(config_path.parent)
- from utils import atomic_roundtrip_yaml_update
- atomic_roundtrip_yaml_update(config_path, key_path, value)
+ from hermes_cli.config_backend import write_config_key
+ write_config_key(config_path, key_path, value)
try: # owner-only: config files contain API keys
os.chmod(config_path, 0o600)
except (OSError, NotImplementedError):
@@ -1040,14 +1040,15 @@ class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin,
def show_config(self):
"""Display current configuration with kawaii ASCII art."""
+ from hermes_cli.config_backend import config_exists
terminal_env = os.getenv("TERMINAL_ENV", "local")
terminal_cwd = os.getenv("TERMINAL_CWD", os.getcwd())
terminal_timeout = os.getenv("TERMINAL_TIMEOUT", "60")
config_path = _hermes_home / 'config.yaml'
- if not config_path.exists():
+ if not config_exists(config_path):
config_path = Path(__file__).parent / 'cli-config.yaml'
- config_status = _t("cli.config.loaded") if config_path.exists() else _t("cli.config.not_found")
+ config_status = _t("cli.config.loaded") if config_exists(config_path) else _t("cli.config.not_found")
# ``api_key`` may be a callable bearer provider (Entra ID, Vertex OAuth, key_cmd, ...):
# label it by its tagged source and never invoke it (that would mint a token just to mask
diff --git a/cron/jobs.py b/cron/jobs.py
index 7fed57ffba..5558dae3ad 100644
--- a/cron/jobs.py
+++ b/cron/jobs.py
@@ -1656,10 +1656,11 @@ def _main_model_pin() -> tuple[Optional[str], Optional[str]]:
"""``(provider, model)`` the main agent runs on right now (``model.default`` + the provider it
resolves to), for ``pinned=True`` jobs: the lock is a plain per-job pin, so the scheduler needs
no second precedence axis. ``(None, None)`` when nothing is configured (the job stays unpinned)."""
+ from hermes_cli.config_backend import config_exists
from hermes_cli.config_effective import load_user_config_effective
cfg_path = get_hermes_home() / "config.yaml"
- cfg = load_user_config_effective(cfg_path) if cfg_path.exists() else {}
+ cfg = load_user_config_effective(cfg_path) if config_exists(cfg_path) else {}
model_cfg = cfg.get("model") or {}
model = model_cfg.get("default") or model_cfg.get("model") if isinstance(model_cfg, dict) else model_cfg
model = _normalize_job_optional_text(model)
diff --git a/cron/scheduler.py b/cron/scheduler.py
index 2092d1179e..7df27bf29e 100644
--- a/cron/scheduler.py
+++ b/cron/scheduler.py
@@ -1635,6 +1635,7 @@ def _load_cron_job_config(job: dict, job_id: str, job_name: str) -> _CronJobConf
the main agent model (config ``model:``, then HERMES_MODEL) > the claimed welcome trial's. Re-read
every tick (no cache) so ``hermes cron edit --model`` and ``hermes model`` both apply next tick."""
from hermes_cli.anon_auth import trial_model
+ from hermes_cli.config_backend import config_exists
model = job.get("model") or cron_env_setting("HERMES_MODEL") or ""
_cron_default_provider = ""
_cfg: dict = {}
@@ -1642,7 +1643,7 @@ def _load_cron_job_config(job: dict, job_id: str, job_name: str) -> _CronJobConf
try:
from hermes_cli.config_effective import load_user_config_effective
_cfg_path = str(_get_hermes_home() / "config.yaml")
- if os.path.exists(_cfg_path):
+ if config_exists(_cfg_path):
_cfg = load_user_config_effective(Path(_cfg_path))
# Coerce null to {} so a falsy default never clobbers a resolved env value.
_model_cfg = _cfg.get("model") or {}
diff --git a/gateway/config_loader.py b/gateway/config_loader.py
index 3328704ceb..4081ff1c64 100644
--- a/gateway/config_loader.py
+++ b/gateway/config_loader.py
@@ -493,15 +493,12 @@ def read_yaml_layers(home: Path) -> dict:
(``gateway.relay.relay_explicitly_disabled``) reads through here so it cannot disagree with
``load_gateway_config()`` on which files count.
"""
- config_yaml_path = home / "config.yaml"
- yaml_cfg: dict = {}
- if config_yaml_path.exists():
- # The installer seeds config.yaml by copying cli-config.yaml.example (~120 KB, almost all
- # comments), and nothing caches this loader — so the pure-Python parser dominates the load.
- from utils import fast_safe_load
+ from hermes_cli.config_backend import get_config_backend
- with open(config_yaml_path, encoding="utf-8-sig") as f:
- yaml_cfg = fast_safe_load(f) or {}
+ backend = get_config_backend()
+ yaml_cfg: dict = {}
+ if backend.exists(home):
+ yaml_cfg = backend.read_user_layer(home).doc or {}
from hermes_cli.config import _expand_env_vars
diff --git a/gateway/readiness.py b/gateway/readiness.py
index 3de2f60eb4..651aab2102 100644
--- a/gateway/readiness.py
+++ b/gateway/readiness.py
@@ -9,7 +9,6 @@ from pathlib import Path
from typing import Any
from hermes_constants import get_hermes_home
-from utils import load_yaml_file_readonly
_DISK_DEGRADED_PERCENT = 90.0
@@ -50,11 +49,12 @@ def _probe_state_db(home: Path) -> dict[str, Any]:
def _probe_config(home: Path) -> dict[str, Any]:
+ from hermes_cli.config_backend import config_exists, read_config_doc_readonly
path = home / "config.yaml"
- if not path.exists():
+ if not config_exists(path):
return _check("ok", "using defaults")
try:
- raw = load_yaml_file_readonly(path)
+ raw = read_config_doc_readonly(path)
except Exception as exc:
return _check("degraded", f"invalid config ({type(exc).__name__})")
return _check("ok") if raw is None or isinstance(raw, dict) else _check("degraded", "top level is not a mapping")
diff --git a/gateway/run.py b/gateway/run.py
index 5ae6dc85e0..b1f214c65c 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -1601,11 +1601,12 @@ def _bridge_max_turns_from_config(home: Path) -> None:
"""Re-bridge agent.max_turns (+ sessions.*) per turn; managed overlay applies or it reverts.
Skipped inside a served secondary's scope: the env slots are the launch profile's and
hermes_state reads the routed profile's ``sessions.*`` from its own config under scope."""
+ from hermes_cli.config_backend import config_exists
from gateway.platforms._shared import profile_scoped
if profile_scoped():
return
config_path = home / 'config.yaml'
- if not config_path.exists():
+ if not config_exists(config_path):
return
try:
cfg = _load_bridge_config(config_path)
@@ -1621,13 +1622,14 @@ def _current_max_iterations() -> int:
``int()`` crash. A routed profile (HERMES_HOME override, multiplexed turns) reads ITS
``agent.max_turns`` straight from config: the ``HERMES_MAX_ITERATIONS`` bridge is one process-wide
slot holding the launch profile's value, so every secondary would inherit the default's budget."""
+ from hermes_cli.config_backend import config_exists
_reload_runtime_env_preserving_config_authority()
from hermes_cli.config import resolve_turn_limit as _resolve_turn_limit
override = get_hermes_home_override()
if override:
config_path = Path(override) / 'config.yaml'
try:
- cfg = _load_bridge_config(config_path) if config_path.exists() else {}
+ cfg = _load_bridge_config(config_path) if config_exists(config_path) else {}
except Exception:
cfg = {}
agent_cfg = cfg.get("agent")
@@ -2128,7 +2130,8 @@ def _load_bridge_config(config_path: Path) -> dict:
_config_path = _hermes_home / 'config.yaml'
_cfg: dict = {}
-if _config_path.exists():
+from hermes_cli.config_backend import config_exists
+if config_exists(_config_path):
try:
_cfg = _load_bridge_config(_config_path)
_bridge_config_to_env(_cfg)
diff --git a/gateway/run_config_loaders.py b/gateway/run_config_loaders.py
index 5230924b18..512216d8fe 100644
--- a/gateway/run_config_loaders.py
+++ b/gateway/run_config_loaders.py
@@ -532,6 +532,7 @@ class GatewayConfigLoadersMixin:
and keeps one last-known-good chain per home: a single runner-wide slot filled from the launch
home handed every secondary profile the default profile's fallback chain.
"""
+ from hermes_cli.config_backend import config_exists
from gateway.run import _gateway_config_home
from hermes_constants import hermes_home_key
home = _gateway_config_home()
@@ -542,7 +543,7 @@ class GatewayConfigLoadersMixin:
try:
from hermes_cli.config_effective import load_user_config_effective
cfg_path = home / "config.yaml"
- if not cfg_path.exists():
+ if not config_exists(cfg_path):
by_home[home_key] = self._fallback_model = None
return self._fallback_model
# fail_closed: a torn mid-edit write must raise so the per-home last known-good chain
diff --git a/hermes_cli/AGENTS.md b/hermes_cli/AGENTS.md
index b7aa264b16..fcfcee48b9 100644
--- a/hermes_cli/AGENTS.md
+++ b/hermes_cli/AGENTS.md
@@ -96,6 +96,13 @@ set/get/unset ` route any bare name registered in `OPTIONAL_ENV_VARS` / `_
`atomic_yaml_write` / `yaml.dump` / `yaml.safe_dump` on a config path — `scripts/check_config_yaml_writers.py`
(CI lint) rejects it, and `tests/hermes_cli/test_config_yaml_comment_preservation.py` guards each
path (#92554). The commented example blocks are appended only when the file is created.
+- **Config backend seam:** every read, stat, existence check and write of a user `config.yaml`
+ goes through `hermes_cli/config_backend.py` (`read_config_doc`, `read_config_doc_readonly`,
+ `config_version`, `config_exists`, `write_config_key`, `write_config_document`). The backend
+ is chosen by `HERMES_CONFIG_BACKEND` only (default `file`; never by a config value). Tools that
+ copy, edit or back up the file itself check `supports_file_tooling()` first.
+ `scripts/check_config_yaml_readers.py` (CI lint) rejects a direct `open`/`read_text`/`stat`/
+ `exists`/YAML load of a config path; mark a true false positive `# config-reader: ok — `.
- **Three loaders — know which you're in:** `load_cli_config()` (CLI, `cli.py`); `load_config()`
(`hermes tools/setup`, most subcommands, `hermes_cli/config.py`, merges `DEFAULT_CONFIG`);
`hermes_cli/config_effective.py::load_user_config_effective()` (gateway runtime via
diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py
index c02974bd6d..0aedc13174 100644
--- a/hermes_cli/auth.py
+++ b/hermes_cli/auth.py
@@ -2417,8 +2417,9 @@ def _logout_default_provider_from_config() -> Optional[str]:
def _reset_config_provider() -> Path:
"""Reset config.yaml provider back to auto after logout."""
+ from hermes_cli.config_backend import config_exists
config_path = get_config_path()
- if not config_path.exists():
+ if not config_exists(config_path):
return config_path
require_readable_config_before_write(config_path)
config = read_raw_config()
diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py
index d50dbee9da..3790935578 100644
--- a/hermes_cli/backup.py
+++ b/hermes_cli/backup.py
@@ -766,7 +766,7 @@ def run_import(args) -> Optional[int]:
print(f"Detected archive prefix: {prefix!r} (will be stripped)")
# Check for existing installation
- has_config = (hermes_root / "config.yaml").exists()
+ has_config = (hermes_root / "config.yaml").exists() # config-reader: ok — profile marker for the backup scope (file tooling)
has_env = (hermes_root / ".env").exists()
if (has_config or has_env) and not args.force:
@@ -863,7 +863,7 @@ def run_import(args) -> Optional[int]:
continue
profile_name = entry.name
# Only create wrappers for directories with config
- if not (entry / "config.yaml").exists() and not (entry / ".env").exists():
+ if not (entry / "config.yaml").exists() and not (entry / ".env").exists(): # config-reader: ok — profile marker for the backup scope (file tooling)
continue
collision = check_alias_collision(profile_name)
if collision:
diff --git a/hermes_cli/cli_config_load.py b/hermes_cli/cli_config_load.py
index 9fa18198e4..281dd848b8 100644
--- a/hermes_cli/cli_config_load.py
+++ b/hermes_cli/cli_config_load.py
@@ -12,7 +12,6 @@ import os
import sys
from pathlib import Path
from typing import List, Dict, Any
-from utils import fast_safe_load
# Log-record parity with the origin module.
logger = logging.getLogger("cli")
@@ -262,8 +261,10 @@ def load_cli_config() -> dict[str, Any]:
``HERMES_IGNORE_USER_CONFIG=1`` skips the user config entirely (``.env`` still loads).
"""
from cli import _cli_config_defaults, _hermes_home, _merge_file_config, _mirror_config_to_env
+ from hermes_cli.config_backend import config_exists, read_config_doc
config_path = _hermes_home / 'config.yaml'
- if not config_path.exists() or os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1":
+ # A backend whose user layer always exists never takes the repo-file fallback.
+ if not config_exists(config_path) or os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1":
config_path = Path(__file__).parent / 'cli-config.yaml'
defaults = _cli_config_defaults()
@@ -271,12 +272,11 @@ def load_cli_config() -> dict[str, Any]:
# Only a file's terminal section may overwrite terminal env vars already set by .env.
_file_has_terminal_config = False
- if config_path.exists():
+ if config_exists(config_path):
try:
- with open(config_path, "r", encoding="utf-8-sig") as f:
- from hermes_cli.config import _normalize_root_model_keys
+ from hermes_cli.config import _normalize_root_model_keys
- file_config = _normalize_root_model_keys(fast_safe_load(f) or {})
+ file_config = _normalize_root_model_keys(read_config_doc(config_path) or {})
_file_has_terminal_config = "terminal" in file_config
_merge_file_config(defaults, file_config)
diff --git a/hermes_cli/cli_info_mixin.py b/hermes_cli/cli_info_mixin.py
index 1d9b4b87fe..772946da56 100644
--- a/hermes_cli/cli_info_mixin.py
+++ b/hermes_cli/cli_info_mixin.py
@@ -18,7 +18,7 @@ from typing import Any, NamedTuple
from agent.i18n import t
from hermes_constants import is_termux as _is_termux_environment
from rich.markup import escape as _escape
-from utils import base_url_hostname, file_signature
+from utils import base_url_hostname
from hermes_cli.cli_modal_mixin import _gated_confirm
from hermes_cli.colors import Colors as _Colors
@@ -864,7 +864,7 @@ class CLIInfoMixin:
cache** (the next message re-sends the full input prefix, expensive on long-context / high-reasoning
models). See #1474.
"""
- import hermes_yaml as _yaml
+ from hermes_cli.config_backend import config_exists, config_version, read_config_doc
now = time.monotonic()
if now - self._last_config_check < CONFIG_WATCH_INTERVAL:
@@ -873,10 +873,10 @@ class CLIInfoMixin:
from hermes_cli.config import get_config_path as _get_config_path
cfg_path = _get_config_path()
- if not cfg_path.exists():
+ if not config_exists(cfg_path):
return
try:
- sig = file_signature(cfg_path.stat())
+ sig = config_version(cfg_path)
except OSError:
return
if sig == self._config_sig:
@@ -884,8 +884,7 @@ class CLIInfoMixin:
self._config_sig = sig
try:
- with open(cfg_path, encoding="utf-8-sig") as f:
- new_cfg = _yaml.safe_load(f) or {}
+ new_cfg = read_config_doc(cfg_path) or {}
except Exception:
return
diff --git a/hermes_cli/cli_tui_mixin.py b/hermes_cli/cli_tui_mixin.py
index 2fd80416f4..66cc4c4497 100644
--- a/hermes_cli/cli_tui_mixin.py
+++ b/hermes_cli/cli_tui_mixin.py
@@ -1817,6 +1817,7 @@ class CLITuiMixin:
def _tui_init_run_state(self):
"""Reset the per-run REPL state (queues, modal states, voice state, config watcher)."""
+ from hermes_cli.config_backend import config_exists, config_version
self._agent_running = False
self._pending_input = queue.Queue() # normal input (commands + new queries)
self._interrupt_queue = queue.Queue() # messages typed while the agent is running
@@ -1835,9 +1836,8 @@ class CLITuiMixin:
# Config file watcher — detect mcp_servers changes and auto-reload.
from hermes_cli.config import get_config_path as _get_config_path
- from utils import file_signature
_cfg_path = _get_config_path()
- self._config_sig: tuple | None = file_signature(_cfg_path.stat()) if _cfg_path.exists() else None
+ self._config_sig: tuple | None = config_version(_cfg_path) if config_exists(_cfg_path) else None
self._config_mcp_servers: dict = self.config.get("mcp_servers") or {}
self._last_config_check: float = 0.0 # monotonic time of last check
diff --git a/hermes_cli/commands_completion.py b/hermes_cli/commands_completion.py
index 56b2cb34b6..3a0990d43e 100644
--- a/hermes_cli/commands_completion.py
+++ b/hermes_cli/commands_completion.py
@@ -31,13 +31,12 @@ def _personalities_from_cli_config() -> dict[str, Any]:
Falls back to a fresh load when the file cannot be stat'ed."""
global _personalities_memo
from cli import load_cli_config
- from utils import file_signature
from hermes_cli.personality import available_personalities
try:
from hermes_cli.config import get_config_path
+ from hermes_cli.config_backend import config_version
cfg_path = get_config_path()
- st = cfg_path.stat()
- sig = (str(cfg_path), *file_signature(st))
+ sig = (str(cfg_path), *config_version(cfg_path))
except Exception:
sig = (None, None, None, None, None)
if _personalities_memo is None or _personalities_memo[0] != sig:
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 18d348a668..dec6b7da01 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -47,6 +47,8 @@ from hermes_constants import (
# Re-export from hermes_constants — canonical definition lives there.
from hermes_constants import get_hermes_home, get_process_hermes_home
from utils import atomic_replace, fast_safe_load, file_signature, mkstemp_beside
+from hermes_cli.config_backend import (
+ config_exists, config_version, read_config_doc, supports_file_tooling, write_config_document)
from hermes_cli.config_read_errors import (
_CONFIG_PARSE_FAILURES, _FIX_PERMS, _FIX_YAML, FailedConfigRead, _backups_dir_display,
_refuse_failed_read, _refuse_overwrite, _warn_config_parse_failure, _yaml_error_details,
@@ -444,8 +446,7 @@ def require_parseable_user_config(*, ignore_user_config: bool = False) -> None:
config_path = get_config_path()
try:
- with open(config_path, encoding="utf-8-sig") as f:
- data = fast_safe_load(f)
+ data = read_config_doc(config_path)
except FileNotFoundError:
return
except Exception as exc:
@@ -1260,9 +1261,12 @@ def _persist_migration(config: dict[str, Any]) -> None:
persist values that DIFFER from the schema default, plus explicit removals/renames of user
data. Every migration step MUST write through here (``save_config`` with default-stripping
ON, no ``merge_existing``) so the invariant cannot regress one migration at a time. A migration
- is Hermes' own write, never a user turning a feature off."""
+ is Hermes' own write, never a user turning a feature off. A backend without file tooling
+ migrates in memory on read and never writes back (D12)."""
from hermes_cli.observability.shared_metrics_disabled import hermes_applied_write
+ if not supports_file_tooling():
+ return
with hermes_applied_write():
save_config(config)
@@ -1884,6 +1888,12 @@ def _raw_config_cache_hit(path_key: str, cache_key: tuple[Any, ...]) -> Optional
return None
+def _raw_config_cache_store(path_key: str, cache_key: tuple[Any, ...], data: dict[str, Any]) -> None:
+ """Publish ``data`` (caller-owned copy) as the raw config for ``path_key`` at ``cache_key``.
+ One whole-tuple replace, so the lock-free readers see either the old or the new entry."""
+ _RAW_CONFIG_CACHE[path_key] = (*cache_key, data)
+
+
def _worker_config_snapshot() -> Optional[dict[str, Any]]:
"""A safe worker's frozen config, or None. Install-time loads run from ``hermes_cli`` alone
(the installer's completion step ships no ``agent`` package), and without ``agent`` there
@@ -1909,7 +1919,7 @@ def _read_raw_config_impl(*, want_deepcopy: bool) -> dict[str, Any]:
# every cached read for the duration). A lost race just falls through to the locked re-check.
try:
config_path = get_config_path()
- cache_key = file_signature(config_path.stat())
+ cache_key = config_version(config_path)
hit = _raw_config_cache_hit(str(config_path), cache_key)
if hit is not None:
return copy.deepcopy(hit) if want_deepcopy else hit
@@ -1919,7 +1929,7 @@ def _read_raw_config_impl(*, want_deepcopy: bool) -> dict[str, Any]:
with _CONFIG_LOCK:
config_path = get_config_path()
try:
- cache_key = file_signature(config_path.stat())
+ cache_key = config_version(config_path)
except FileNotFoundError:
return {}
except OSError as e:
@@ -1931,8 +1941,7 @@ def _read_raw_config_impl(*, want_deepcopy: bool) -> dict[str, Any]:
return copy.deepcopy(hit) if want_deepcopy else hit
try:
- with open(config_path, encoding="utf-8-sig") as f:
- data = fast_safe_load(f) or {}
+ data = read_config_doc(config_path) or {}
except Exception as e:
_warn_config_parse_failure(config_path, e)
return FailedConfigRead(error=e)
@@ -1943,7 +1952,7 @@ def _read_raw_config_impl(*, want_deepcopy: bool) -> dict[str, Any]:
# The cache stores its own deepcopy. The readonly path returns THAT object (identity
# invariant: later cache hits return the same dict); the mutable path returns the parse.
cached_copy = copy.deepcopy(data)
- _RAW_CONFIG_CACHE[path_key] = (*cache_key, cached_copy)
+ _raw_config_cache_store(path_key, cache_key, cached_copy)
return data if want_deepcopy else cached_copy
@@ -1960,8 +1969,7 @@ def read_user_config_raw(config_path: Optional[Path] = None) -> dict[str, Any]:
if config_path is None:
config_path = get_config_path()
try:
- with open(config_path, encoding="utf-8-sig") as f:
- data = fast_safe_load(f) or {}
+ data = read_config_doc(config_path) or {}
except FileNotFoundError:
return {}
return data if isinstance(data, dict) else {}
@@ -1987,15 +1995,14 @@ def require_readable_config_before_write(config_path: Optional[Path] = None) ->
return copy.deepcopy(snapshot)
config_path = get_config_path()
try:
- config_path.stat()
+ config_version(config_path)
except FileNotFoundError:
return {}
except OSError as exc:
raise _refuse_overwrite(config_path, "cannot be accessed", exc, _FIX_PERMS) from exc
try:
- with open(config_path, encoding="utf-8-sig") as f:
- loaded = fast_safe_load(f)
+ loaded = read_config_doc(config_path)
except OSError as exc:
raise _refuse_overwrite(config_path, "cannot be read", exc, _FIX_PERMS) from exc
except Exception as exc:
@@ -2043,8 +2050,6 @@ def _write_config_state(
extra_content_on_create: Optional[str] = None,
) -> None:
"""Shared comment-preserving config writer; omission policy is selected by the public wrapper."""
- from utils import atomic_roundtrip_yaml_save
-
_refuse_failed_read(config_path, data)
if not allow_omissions:
existing = require_readable_config_before_write(config_path)
@@ -2061,7 +2066,7 @@ def _write_config_state(
"Pass the complete current config, or use atomic_config_replace() only when "
"deletion by omission is deliberate.",
) from exc
- atomic_roundtrip_yaml_save(config_path, data, extra_content_on_create=extra_content_on_create)
+ write_config_document(config_path, data, extra_content_on_create=extra_content_on_create)
def atomic_config_write(
@@ -2223,13 +2228,12 @@ def _load_config_cache_sig(config_path: Path) -> tuple[Optional[tuple[int, int,
The managed config file's signature is folded in ((0, 0, 0, 0) = none) so editing it invalidates
the merged result. ``cache_sig`` is None only when neither file exists (nothing to cache on)."""
try:
- st = config_path.stat()
- user_sig: Optional[tuple[int, int, int, int]] = file_signature(st)
+ user_sig: Optional[tuple[Any, ...]] = config_version(config_path)
except FileNotFoundError:
user_sig = None
managed_dir = managed_scope.get_managed_dir()
try:
- mst = (managed_dir / "config.yaml").stat() if managed_dir else None
+ mst = (managed_dir / "config.yaml").stat() if managed_dir else None # config-reader: ok — managed overlay, not a user layer
managed_sig = file_signature(mst) if mst else (0, 0, 0, 0)
except OSError:
managed_sig = (0, 0, 0, 0)
@@ -2304,19 +2308,23 @@ def _load_config_cache_hit(path_key: str, cache_sig: Any) -> Optional[dict[str,
pin unexpanded literals (e.g. auxiliary..api_key) for the process lifetime (#58514).
Shared by the lock-free fast path and the locked re-check of ``_load_config_impl``."""
cached = _LOAD_CONFIG_CACHE.get(path_key)
- if cached is None or cache_sig is None or cached[:8] != cache_sig:
+ if cached is None or cache_sig is None:
return None
- hit = cached[8]
+ n = len(cache_sig) # the backend's version tuple + the managed file's signature
+ if cached[:n] != cache_sig:
+ return None
+ hit = cached[n]
if isinstance(hit, FailedConfigRead) and isinstance(hit.read_error, OSError):
# A read error (EMFILE/EIO/sharing violation) can clear without touching the file's
# signature: serve the fallback only while the file still cannot be read.
try:
- with open(path_key, "rb") as f:
- f.read()
- return None
+ read_config_doc(path_key)
except OSError:
return hit
- env_snapshot = cached[9] if len(cached) > 9 else {}
+ except Exception:
+ pass # readable again; the reload reports the parse error
+ return None
+ env_snapshot = cached[n + 1] if len(cached) > n + 1 else {}
if all(_env_ref_lookup(k) == v for k, v in env_snapshot.items()):
return hit
return None
@@ -2359,8 +2367,7 @@ def _load_config_impl(*, want_deepcopy: bool) -> dict[str, Any]:
if user_sig is not None:
try:
- with open(config_path, encoding="utf-8-sig") as f:
- user_config = fast_safe_load(f) or {}
+ user_config = read_config_doc(config_path) or {}
_CONFIG_PARSE_FAILURES.pop(path_key, None) # the file reads now (a transient error left the record)
if "max_turns" in user_config:
@@ -2995,8 +3002,12 @@ def edit_config():
if is_managed():
managed_error("edit configuration")
return
+ if not supports_file_tooling():
+ print("`hermes config edit` opens the local config file, and this config backend has none. "
+ "Use `hermes config set ` instead.", file=sys.stderr)
+ return
config_path = get_config_path()
- if not config_path.exists():
+ if not config_path.exists(): # config-reader: ok — file tooling, gated on supports_file_tooling()
seed_config_file(config_path)
print(f"Created {config_path}")
diff --git a/hermes_cli/config_backend.py b/hermes_cli/config_backend.py
new file mode 100644
index 0000000000..15c4a8264c
--- /dev/null
+++ b/hermes_cli/config_backend.py
@@ -0,0 +1,219 @@
+"""Where the user config layer lives: the ``ConfigBackend`` seam (config-config design §4.1, D10/D11).
+
+Every read and write of a profile's user ``config.yaml`` goes through the backend returned by
+:func:`get_config_backend` (``scripts/check_config_yaml_readers.py`` and
+``scripts/check_config_yaml_writers.py`` reject direct file access elsewhere). The key per call is
+the hermes home (profile): one multiplexed process serves N profiles, and every config cache
+already keys on the config path, so a backend keeps per-home state.
+
+:class:`FileBackend` is the only backend in this build and is today's behaviour byte for byte:
+the version is the ``file_signature`` stat tuple, reads are ``open`` + ``fast_safe_load`` and raise
+exactly what those raise (``FileNotFoundError`` for an absent file, ``OSError``, YAML errors), and
+writes are the ruamel round-trip writers in ``utils``. Callers keep their own error handling, so a
+backend whose layer always exists simply never takes the "no file" branches.
+
+The managed scope (``/etc/hermes``) is NOT a backend concern: it stays an overlay applied on top of
+whatever user layer the backend returns.
+"""
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Mapping, Optional, Protocol, Tuple, Union, runtime_checkable
+
+CONFIG_FILENAME = "config.yaml"
+BACKEND_ENV = "HERMES_CONFIG_BACKEND"
+
+PathLike = Union[str, "os.PathLike[str]"]
+
+
+@dataclass(frozen=True)
+class UserLayer:
+ """One read of a profile's user layer."""
+ doc: Any # the parsed root as written (None for an empty document)
+ version: Tuple[Any, ...] # the cache signature this doc was read at (etag for a remote backend)
+ locks: Mapping[str, str] = field(default_factory=dict) # dotted prefix -> level that locks it
+ provenance: str = "" # where the doc came from, for diagnostics
+
+
+@dataclass(frozen=True)
+class Changes:
+ """A write to one profile's user layer.
+
+ ``document`` replaces the whole user-layer document (``atomic_config_write``: the file backend
+ merges it onto the on-disk document, comment-preserving); ``set`` / ``unset`` are single dotted
+ keys (``atomic_roundtrip_yaml_update`` semantics). A backend applies ``document`` first.
+ """
+ document: Optional[dict] = None
+ set: Mapping[str, Any] = field(default_factory=dict)
+ unset: Tuple[str, ...] = ()
+ extra_content_on_create: Optional[str] = None
+
+
+@runtime_checkable
+class ConfigBackend(Protocol):
+ name: str
+
+ def read_user_layer(self, home: Path) -> UserLayer: ...
+
+ def read_user_doc_readonly(self, home: Path) -> Any:
+ """``read_user_layer(home).doc`` re-read only when ``version`` changes; never mutate it."""
+ ...
+
+ def version(self, home: Path) -> Tuple[Any, ...]:
+ """Cheap cache signature; changes whenever the user layer does."""
+ ...
+
+ def exists(self, home: Path) -> bool: ...
+
+ def write_changes(self, home: Path, changes: Changes) -> None: ...
+
+ def locked(self, home: Path, dotted: str) -> Optional[str]:
+ """The level that locks ``dotted`` (prefix-aware), or None."""
+ ...
+
+ def supports_file_tooling(self) -> bool:
+ """Whether tools that copy/edit/back up the config FILE (profile clone, backup/restore,
+ ``config edit``, last-known-good backups, ``migrate_config`` write-back) may run."""
+ ...
+
+
+class ConfigBackendUnavailable(SystemExit):
+ """The selected backend cannot serve config. A ``SystemExit`` on purpose: many config readers
+ fail open with ``except Exception`` → defaults, and serving defaults instead of the selected
+ backend's config is exactly the silent failure this must not become."""
+
+
+class FileBackend:
+ """The user layer is ``/config.yaml`` on local disk."""
+
+ name = "file"
+
+ # Path-level primitives: the file backend's whole behaviour, also used for explicit files
+ # that are not a home's config.yaml (see ``_route``).
+ @staticmethod
+ def version_path(path: Path) -> Tuple[int, int, int, int]:
+ from utils import file_signature
+ return file_signature(path.stat())
+
+ @staticmethod
+ def read_path(path: Path) -> Any:
+ from utils import fast_safe_load
+ with open(path, encoding="utf-8-sig") as f:
+ return fast_safe_load(f)
+
+ @staticmethod
+ def read_path_readonly(path: Path) -> Any:
+ from utils import load_yaml_file_readonly
+ return load_yaml_file_readonly(path)
+
+ @staticmethod
+ def write_path(path: Path, changes: Changes) -> None:
+ from utils import atomic_roundtrip_yaml_save, atomic_roundtrip_yaml_update
+ if changes.document is not None:
+ atomic_roundtrip_yaml_save(path, changes.document, extra_content_on_create=changes.extra_content_on_create)
+ for key, value in changes.set.items():
+ atomic_roundtrip_yaml_update(path, key, value)
+ for key in changes.unset:
+ atomic_roundtrip_yaml_update(path, key, None)
+
+ def config_path(self, home: Path) -> Path:
+ return Path(home) / CONFIG_FILENAME
+
+ def read_user_layer(self, home: Path) -> UserLayer:
+ path = self.config_path(home)
+ version = self.version_path(path)
+ return UserLayer(doc=self.read_path(path), version=version, provenance=str(path))
+
+ def read_user_doc_readonly(self, home: Path) -> Any:
+ return self.read_path_readonly(self.config_path(home))
+
+ def version(self, home: Path) -> Tuple[int, int, int, int]:
+ return self.version_path(self.config_path(home))
+
+ def exists(self, home: Path) -> bool:
+ return self.config_path(home).exists()
+
+ def write_changes(self, home: Path, changes: Changes) -> None:
+ self.write_path(self.config_path(home), changes)
+
+ def locked(self, home: Path, dotted: str) -> Optional[str]:
+ return None # a local file has no levels; the managed scope is a separate overlay
+
+ def supports_file_tooling(self) -> bool:
+ return True
+
+
+_FILE_BACKEND = FileBackend()
+
+
+def get_config_backend() -> ConfigBackend:
+ """The backend selected by ``HERMES_CONFIG_BACKEND`` (default ``file``).
+
+ Read from the environment on every call, before any config read, so no config value can
+ select it (D11). Raises :class:`ConfigBackendUnavailable` for a backend this build lacks.
+ """
+ kind = os.environ.get(BACKEND_ENV, "").strip().lower() or "file"
+ if kind == "file":
+ return _FILE_BACKEND
+ if kind == "remote":
+ raise ConfigBackendUnavailable(
+ f"{BACKEND_ENV}=remote: the remote config backend is not available in this build of "
+ "Hermes. Unset it to use the local config.yaml.")
+ raise ConfigBackendUnavailable(f"{BACKEND_ENV}={kind!r} is not a config backend (expected 'file').")
+
+
+def _route(config_path: PathLike) -> Tuple[Any, Path, bool]:
+ """``(backend, home_or_path, is_user_layer)`` for a config path.
+
+ ``/config.yaml`` is that home's user layer and goes to the selected backend. Any other
+ file name is an explicit file (a test fixture, an import source), not a user layer: it is read
+ and written with the file backend's primitives regardless of the selected backend.
+ """
+ path = Path(config_path)
+ if path.name == CONFIG_FILENAME:
+ return get_config_backend(), path.parent, True
+ return _FILE_BACKEND, path, False
+
+
+def read_config_doc(config_path: PathLike) -> Any:
+ """Parsed root of a config file (None when empty); raises like ``open`` + ``fast_safe_load``."""
+ backend, target, is_layer = _route(config_path)
+ return backend.read_user_layer(target).doc if is_layer else backend.read_path(target)
+
+
+def read_config_doc_readonly(config_path: PathLike) -> Any:
+ """Signature-cached :func:`read_config_doc`; the result is shared — never mutate it."""
+ backend, target, is_layer = _route(config_path)
+ return backend.read_user_doc_readonly(target) if is_layer else backend.read_path_readonly(target)
+
+
+def config_version(config_path: PathLike) -> Tuple[Any, ...]:
+ """Cache signature of a config file; raises ``FileNotFoundError`` / ``OSError`` like ``stat``."""
+ backend, target, is_layer = _route(config_path)
+ return backend.version(target) if is_layer else backend.version_path(target)
+
+
+def config_exists(config_path: PathLike) -> bool:
+ backend, target, is_layer = _route(config_path)
+ return backend.exists(target) if is_layer else target.exists()
+
+
+def write_config_document(config_path: PathLike, document: dict, *, extra_content_on_create: Optional[str] = None) -> None:
+ """Replace a config file's document (comment-preserving merge for the file backend)."""
+ backend, target, is_layer = _route(config_path)
+ changes = Changes(document=document, extra_content_on_create=extra_content_on_create)
+ backend.write_changes(target, changes) if is_layer else backend.write_path(target, changes)
+
+
+def write_config_key(config_path: PathLike, key_path: str, value: Any) -> None:
+ """Set one dotted key (``None`` removes it) — ``atomic_roundtrip_yaml_update`` semantics."""
+ backend, target, is_layer = _route(config_path)
+ changes = Changes(unset=(key_path,)) if value is None else Changes(set={key_path: value})
+ backend.write_changes(target, changes) if is_layer else backend.write_path(target, changes)
+
+
+def supports_file_tooling() -> bool:
+ """Gate for tools that copy, edit or back up the config FILE itself (§4.3, §4.7)."""
+ return get_config_backend().supports_file_tooling()
diff --git a/hermes_cli/config_backups.py b/hermes_cli/config_backups.py
index cc90cbb213..e722fa1cf5 100644
--- a/hermes_cli/config_backups.py
+++ b/hermes_cli/config_backups.py
@@ -46,9 +46,13 @@ def backup_config(config_path: Path, reason: str, *, keep: int = DEFAULT_KEEP) -
Skips when the file is missing/empty, or when the newest backup for *reason* already holds
identical bytes. Never raises: a failed backup must not block the write it precedes.
+ Skipped entirely for a config backend without file tooling (no local copies, D2).
"""
+ from hermes_cli.config_backend import supports_file_tooling
+ if not supports_file_tooling():
+ return None
try:
- if not config_path.is_file() or config_path.stat().st_size == 0:
+ if not config_path.is_file() or config_path.stat().st_size == 0: # config-reader: ok — file tooling, gated above
return None
root = backups_dir(config_path)
root.mkdir(parents=True, exist_ok=True)
@@ -77,6 +81,9 @@ def load_newest_good_backup(config_path: Path) -> Optional[dict]:
tried: a backup that fails to parse means the on-disk copy was damaged after the fact, and
guessing further back would serve a config the user never saw as current.
"""
+ from hermes_cli.config_backend import supports_file_tooling
+ if not supports_file_tooling():
+ return None
newest = list_config_backups(config_path, "good")[:1]
if not newest:
return None
diff --git a/hermes_cli/config_effective.py b/hermes_cli/config_effective.py
index ac32724b39..55ff6fcce6 100644
--- a/hermes_cli/config_effective.py
+++ b/hermes_cli/config_effective.py
@@ -20,8 +20,8 @@ from typing import Any, Dict, Optional, Tuple
from hermes_cli import config as _config
from hermes_cli import managed_scope
+from hermes_cli.config_backend import read_config_doc
from hermes_cli.config_read_errors import _warn_config_parse_failure
-from utils import fast_safe_load
# path -> raw user mapping from the last successful parse in this process; served (through the
# normal pipeline) when the file is later found mid-edit as broken YAML.
@@ -73,27 +73,27 @@ def load_user_config_effective(config_path: Optional[Path] = None, *, fail_close
with _config._CONFIG_LOCK:
user_sig, cache_sig = _config._load_config_cache_sig(config_path)
cached = _EFFECTIVE_CACHE.get(path_key)
- if cached is not None and cache_sig is not None and cached[:8] == cache_sig:
- if all(_config._env_ref_lookup(k) == v for k, v in cached[9].items()):
- return copy.deepcopy(cached[8])
+ n = len(cache_sig) if cache_sig is not None else 0
+ if cached is not None and cache_sig is not None and cached[:n] == cache_sig:
+ if all(_config._env_ref_lookup(k) == v for k, v in cached[n + 1].items()):
+ return copy.deepcopy(cached[n])
raw: dict[str, Any] = {}
recovered = False
- raw_hit = _config._RAW_CONFIG_CACHE.get(path_key)
- if user_sig is not None and raw_hit is not None and raw_hit[:4] == user_sig:
- raw = copy.deepcopy(raw_hit[4]) # one parse per process, shared with read_raw_config()
+ raw_hit = _config._raw_config_cache_hit(path_key, user_sig) if user_sig is not None else None
+ if raw_hit is not None:
+ raw = copy.deepcopy(raw_hit) # one parse per process, shared with read_raw_config()
_LAST_GOOD_USER_RAW.setdefault(path_key, copy.deepcopy(raw))
elif user_sig is not None:
try:
- with open(config_path, encoding="utf-8-sig") as f:
- loaded = fast_safe_load(f)
+ loaded = read_config_doc(config_path)
except Exception as exc:
if fail_closed:
raise
raw, recovered = _recover_user_raw(config_path, path_key, exc), True
else:
raw = loaded if isinstance(loaded, dict) else {}
- _config._RAW_CONFIG_CACHE[path_key] = (*user_sig, copy.deepcopy(raw))
+ _config._raw_config_cache_store(path_key, user_sig, copy.deepcopy(raw))
_LAST_GOOD_USER_RAW[path_key] = copy.deepcopy(raw)
# Same copy load_config keeps: a fresh process recovers from it (see _recover_user_raw).
# Only for the ACTIVE home — a read of another profile's file (doctor, TUI cwd lookup)
diff --git a/hermes_cli/config_read_errors.py b/hermes_cli/config_read_errors.py
index d5c78a531e..afad4defaf 100644
--- a/hermes_cli/config_read_errors.py
+++ b/hermes_cli/config_read_errors.py
@@ -13,7 +13,6 @@ import sys
from pathlib import Path
from typing import Any, Optional
-from utils import file_signature
logger = logging.getLogger(__name__)
@@ -76,8 +75,8 @@ def _warn_config_parse_failure(
in ``hermes_cli.config._load_config_impl``).
"""
try:
- st = config_path.stat()
- sig = file_signature(st)
+ from hermes_cli.config_backend import config_version
+ sig = config_version(config_path)
key = (str(config_path), *sig)
_CONFIG_PARSE_FAILURES[str(config_path)] = (*sig, str(exc))
except OSError:
@@ -104,9 +103,10 @@ def get_active_config_parse_failure() -> Optional[str]:
(mtime_ns + size + ino + ctime_ns) to the file that failed to parse; else None."""
from hermes_cli.config import get_config_path
try:
+ from hermes_cli.config_backend import config_version
record = _CONFIG_PARSE_FAILURES[str(path := get_config_path())]
- st = path.stat()
- return record[4] if file_signature(st) == record[:4] else None
+ sig = config_version(path)
+ return record[len(sig)] if sig == record[:len(sig)] else None
except Exception:
return None
diff --git a/hermes_cli/config_version_stamp.py b/hermes_cli/config_version_stamp.py
index ab125e6d0a..df82d83412 100644
--- a/hermes_cli/config_version_stamp.py
+++ b/hermes_cli/config_version_stamp.py
@@ -29,16 +29,16 @@ def read_config_version_stamp(*, raise_on_parse_error: bool = False) -> tuple[Op
never-stamped current-schema file, not an ancient install — ``migrate_config()`` gives it only
the legacy-key steps). A missing file, or malformed YAML under a tolerant caller, reads as
``latest`` exactly as ``check_config_version()`` always reported it."""
- from hermes_cli.config import InvalidUserConfigError, _warn_config_parse_failure, fast_safe_load, get_config_path
+ from hermes_cli.config import InvalidUserConfigError, _warn_config_parse_failure, get_config_path
+ from hermes_cli.config_backend import config_exists, read_config_doc
latest = _coerce_config_version(DEFAULT_CONFIG.get("_config_version", 1)) or 1
config_path = get_config_path()
- if not config_path.exists():
+ if not config_exists(config_path):
return latest, latest
try:
- with open(config_path, encoding="utf-8-sig") as f:
- config = fast_safe_load(f)
+ config = read_config_doc(config_path)
except Exception as e:
_warn_config_parse_failure(config_path, e)
if raise_on_parse_error:
diff --git a/hermes_cli/credential_lifecycle.py b/hermes_cli/credential_lifecycle.py
index 0e88844b99..c87aa3f078 100644
--- a/hermes_cli/credential_lifecycle.py
+++ b/hermes_cli/credential_lifecycle.py
@@ -86,12 +86,13 @@ def _scrub_config_yaml_mirrors(old_value: str, new_value: str | None) -> list[st
``.env`` is touched. ``new_value=None`` removes the field. Operates on the RAW user config
so defaults are never baked into the user's file.
"""
+ from hermes_cli.config_backend import config_exists
if not old_value:
return []
from hermes_cli.config import atomic_config_replace, get_config_path, read_user_config_raw
config_path = get_config_path()
- if not config_path.exists():
+ if not config_exists(config_path):
return []
try:
user_config = read_user_config_raw(config_path)
diff --git a/hermes_cli/doctor_config.py b/hermes_cli/doctor_config.py
index cedcba5014..0f7bd13f39 100644
--- a/hermes_cli/doctor_config.py
+++ b/hermes_cli/doctor_config.py
@@ -335,9 +335,10 @@ def _validate_auxiliary_config(config_path, issues: list) -> None:
@doctor_check()
def _check_config_file(should_fix: bool, f: Finding) -> None:
"""config.yaml presence (project cli-config.yaml as fallback); model/provider validation."""
+ from hermes_cli.config_backend import config_exists
from hermes_cli.doctor import HERMES_HOME, PROJECT_ROOT, _DHH
config_path = HERMES_HOME / 'config.yaml'
- if config_path.exists():
+ if config_exists(config_path):
check_ok(f"{_DHH}/config.yaml exists")
with warn_on_error("Could not validate model/provider config"):
_validate_model_config(config_path, f.issues)
@@ -529,9 +530,10 @@ def _check_config_drift(should_fix: bool, f: Finding) -> None:
Each step is independent and best-effort: a failure in one never hides the next.
"""
+ from hermes_cli.config_backend import config_exists
from hermes_cli.doctor import HERMES_HOME
config_path = HERMES_HOME / 'config.yaml'
- if not config_path.exists():
+ if not config_exists(config_path):
config_path = None
for step in _CONFIG_DRIFT_STEPS if config_path else (_drift_deprecations,):
with warn_on_error(""):
diff --git a/hermes_cli/doctor_state.py b/hermes_cli/doctor_state.py
index 1fb68122d4..5812a8c93b 100644
--- a/hermes_cli/doctor_state.py
+++ b/hermes_cli/doctor_state.py
@@ -16,11 +16,12 @@ from hermes_state_holders import read_only_db_uri
def _doctor_memory_config(hermes_home: Path | None = None) -> dict:
"""Return the effective memory section used by doctor diagnostics."""
+ from hermes_cli.config_backend import config_exists
from hermes_cli.doctor import HERMES_HOME
try:
from hermes_cli.config_effective import load_user_config_effective
config_path = (hermes_home if hermes_home is not None else HERMES_HOME) / "config.yaml"
- if not config_path.exists():
+ if not config_exists(config_path):
return {}
section = load_user_config_effective(config_path).get("memory")
return section if isinstance(section, dict) else {}
@@ -680,6 +681,7 @@ def _check_plugins_in_both_lists(should_fix: bool, f: Finding) -> None:
@doctor_check("") # best-effort: profile enumeration must never break doctor
def _check_profiles(should_fix: bool, f: Finding) -> None:
from hermes_constants import PROFILE_ID_RE
+ from hermes_cli.config_backend import config_exists
from hermes_cli.profiles import _wrapper_path, list_profiles, _get_wrapper_dir, profile_exists
import re as _re
named_profiles = [p for p in list_profiles() if not p.is_default]
@@ -691,7 +693,7 @@ def _check_profiles(should_fix: bool, f: Finding) -> None:
for p in named_profiles:
parts = [text for cond, text in (
(p.gateway_running, "gateway running"), (p.model, (p.model or "")[:30]),
- (not (p.path / "config.yaml").exists(), "⚠ missing config"), (not (p.path / ".env").exists(), "no .env"),
+ (not config_exists(p.path / "config.yaml"), "⚠ missing config"), (not (p.path / ".env").exists(), "no .env"),
(p.alias_path is None, "no alias")) if cond]
check_ok(f" {p.name}: {', '.join(parts) if parts else 'configured'}")
# Orphan wrappers
diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py
index d2055780a4..e3727e8d95 100644
--- a/hermes_cli/env_loader.py
+++ b/hermes_cli/env_loader.py
@@ -15,7 +15,7 @@ from pathlib import Path
# wiped (#57828) so early recovery provably runs before third-party imports (test_early_recovery).
# The parser internals are imported lazily below because gateway tests stub ``sys.modules["dotenv"]``.
import dotenv
-from utils import atomic_replace, load_yaml_file_readonly, mkstemp_beside
+from utils import atomic_replace, mkstemp_beside
logger = logging.getLogger(__name__)
@@ -581,6 +581,13 @@ def load_hermes_dotenv(
if os.environ.get(name) != value:
os.environ[name] = value
+ # The config backend is selected here — after the .env files, before the first config.yaml read
+ # (``_apply_external_secret_sources`` reads ``secrets:``) — so an unavailable backend stops the
+ # process before any reader could fall back to defaults (config-config design §4.4, D11/D32).
+ from hermes_cli.config_backend import get_config_backend
+
+ get_config_backend()
+
# External sources are skipped for the updater (dotenv + managed env still load): ``update`` must not
# import optional secret-manager libs (Bitwarden → cryptography → _rust.pyd) into the process replacing
# that env on Windows, and a fresh retry after a deferred dependency install would otherwise make the
@@ -822,8 +829,10 @@ def _remediation_hint(source_name: str, error_kind, secrets_cfg: dict, *, scope:
def _load_secrets_config(home_path: Path) -> dict:
"""Read just the ``secrets:`` section of config.yaml, isolated so a malformed config can't break dotenv."""
+ from hermes_cli.config_backend import config_exists, read_config_doc_readonly
+
config_path = home_path / "config.yaml"
- if not config_path.exists():
+ if not config_exists(config_path):
return {}
# Prefer the shared raw-config cache: this is the first config.yaml read of a normal startup, so
# populating it lets main.py's early bridge and hermes_logging reuse one parse instead of 3-4.
@@ -837,7 +846,7 @@ def _load_secrets_config(home_path: Path) -> dict:
pass
# Routed profiles re-enter their scope on every poll/turn; only re-parse after the file changed.
try:
- data = load_yaml_file_readonly(config_path) or {}
+ data = read_config_doc_readonly(config_path) or {}
except Exception:
return {}
return data.get("secrets") or {}
diff --git a/hermes_cli/gateway_multiplex_mode.py b/hermes_cli/gateway_multiplex_mode.py
index efdf1e3167..24bfa2a60f 100644
--- a/hermes_cli/gateway_multiplex_mode.py
+++ b/hermes_cli/gateway_multiplex_mode.py
@@ -60,12 +60,13 @@ def explicit_multiplex_flag(default_home: Path) -> Optional[bool]:
``GATEWAY_MULTIPLEX_PROFILES``, else ``gateway.multiplex_profiles`` (or the top-level alias) as
written in its config.yaml; ``None`` when neither is set. Raw read on purpose: the callers are
other processes (``hermes -p X ...`` has X's config loaded) asking about the default's file."""
+ from hermes_cli.config_backend import config_exists
from gateway.config import _bool_token, _env_multiplex_profiles_override
env = _env_multiplex_profiles_override()
if env is not None:
return env
cfg_path = Path(default_home) / "config.yaml"
- if not cfg_path.exists():
+ if not config_exists(cfg_path):
return None
from hermes_cli.config import read_user_config_raw
cfg = read_user_config_raw(cfg_path) or {}
@@ -196,13 +197,14 @@ def persist_resolved_default(decision: MultiplexDecision, default_home: Optional
is made explicit ("left unset" was read as "off"), a retired ``false`` is rewritten in place and
leaves a one-time marker for the boxed notice. Comment-preserving writer, once, and NEVER on a guard
refusal (the file must not say true while the runtime is standalone). Returns True on a write."""
+ from hermes_cli.config_backend import config_exists
if not decision.enabled or decision.source == "guard":
return False
default_home = Path(default_home) if default_home is not None else _default_profile_home()
cfg_path = default_home / "config.yaml"
try:
from hermes_cli.config import read_user_config_raw
- cfg = read_user_config_raw(cfg_path) or {} if cfg_path.exists() else {}
+ cfg = read_user_config_raw(cfg_path) or {} if config_exists(cfg_path) else {}
section = cfg.get("gateway") if isinstance(cfg.get("gateway"), dict) else {}
in_file = cfg.get("multiplex_profiles", section.get("multiplex_profiles"))
if in_file is True:
diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py
index 4279503ff5..557fd2aad8 100644
--- a/hermes_cli/kanban_db.py
+++ b/hermes_cli/kanban_db.py
@@ -4097,7 +4097,7 @@ def list_profiles_on_disk() -> list[str]:
names.add("default")
if profiles_dir.is_dir():
try:
- names.update(e.name for e in profiles_dir.iterdir() if e.is_dir() and (e / "config.yaml").is_file())
+ names.update(e.name for e in profiles_dir.iterdir() if e.is_dir() and (e / "config.yaml").is_file()) # config-reader: ok — profile directory discovery marker
except OSError:
pass
return sorted(names)
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 28717e732e..a38f8588e3 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -259,16 +259,14 @@ def _config_default_interface_early() -> str:
"""Return the configured default interface ("cli"/"tui") via a minimal
YAML read. Best-effort: any error falls back to "cli" (legacy behavior)."""
global _EARLY_INTERFACE_CACHE
+ from hermes_cli.config_backend import config_exists, read_config_doc
cfg_path = _early_interface_config_path()
if _EARLY_INTERFACE_CACHE is not None and _EARLY_INTERFACE_CACHE[0] == cfg_path:
return _EARLY_INTERFACE_CACHE[1]
value = "cli"
try:
- if os.path.exists(cfg_path):
- import hermes_yaml as _yaml_iface
-
- with open(cfg_path, encoding="utf-8-sig") as _f:
- raw = _yaml_iface.safe_load(_f) or {}
+ if config_exists(cfg_path):
+ raw = read_config_doc(cfg_path) or {}
disp = raw.get("display", {})
if isinstance(disp, dict):
iface = disp.get("interface")
@@ -721,6 +719,7 @@ load_hermes_dotenv(project_env=PROJECT_ROOT / ".env")
# .env value still wins — this is config.yaml fallback only. network.force_ipv4
# is read from the same parse to avoid a second full load_config() (~17ms).
_FORCE_IPV4_EARLY = False
+from hermes_cli.config_backend import config_exists
try:
# The effective-config cache (shared raw parse with read_raw_config()) means this SAME parse
# serves hermes_logging, hermes_time and later raw reads: 3-4 config.yaml parses become one.
@@ -728,7 +727,7 @@ try:
from hermes_cli.config_effective import load_user_config_effective as _load_effective_early
_cfg_path = get_hermes_home() / "config.yaml"
- if _cfg_path.exists():
+ if config_exists(_cfg_path):
_early_cfg_raw = _load_effective_early(_cfg_path)
if "HERMES_REDACT_SECRETS" not in os.environ:
_early_sec_cfg = _early_cfg_raw.get("security", {})
diff --git a/hermes_cli/migrate.py b/hermes_cli/migrate.py
index 11f96a7b8c..e322dfc7e7 100644
--- a/hermes_cli/migrate.py
+++ b/hermes_cli/migrate.py
@@ -29,6 +29,7 @@ def _fail(message: str) -> int:
def cmd_migrate_xai(args: Any) -> int:
"""Run xAI May-15 model migration in dry-run or apply mode."""
+ from hermes_cli.config_backend import config_exists
from hermes_cli.xai_retirement import (
MIGRATION_GUIDE_URL, RETIREMENT_DATE, apply_migration, find_retired_xai_refs, format_issue)
@@ -62,7 +63,7 @@ def cmd_migrate_xai(args: Any) -> int:
Colors.DIM))
return 0
- if not config_path or not config_path.exists():
+ if not config_path or not config_exists(config_path):
return _fail(f"Could not locate config.yaml (looked at: {config_path})")
try:
diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py
index 7e1c683b31..0eb7297849 100644
--- a/hermes_cli/model_switch.py
+++ b/hermes_cli/model_switch.py
@@ -19,7 +19,7 @@ from hermes_cli.providers import (
from hermes_cli.model_normalize import normalize_model_for_provider
from agent.models_dev import (
ModelCapabilities, ModelInfo, get_model_capabilities, get_model_info, list_provider_models)
-from utils import base_url_host_matches, base_url_hostname, base_url_origin, file_signature
+from utils import base_url_host_matches, base_url_hostname, base_url_origin
# Re-exported: callers/tests patch hermes_cli.model_switch..
from hermes_cli.model_switch_providers import list_authenticated_providers
@@ -270,14 +270,15 @@ def _direct_alias_source_identity() -> Optional[tuple]:
path = get_config_path()
except Exception:
return None
+ from hermes_cli.config_backend import config_version
try:
- stat = path.stat()
+ version = config_version(path)
except OSError:
# A missing config is still a definite identity for this profile.
return (str(path), None)
except Exception:
return None
- return (str(path), file_signature(stat))
+ return (str(path), version)
def _ensure_direct_aliases() -> None:
diff --git a/hermes_cli/personality.py b/hermes_cli/personality.py
index a438a8920a..8d72c07c6f 100644
--- a/hermes_cli/personality.py
+++ b/hermes_cli/personality.py
@@ -131,12 +131,12 @@ def persist_personality(value: Any) -> bool:
name = normalize_personality_name(value)
try:
from hermes_constants import get_hermes_home
- from utils import atomic_roundtrip_yaml_update
+ from hermes_cli.config_backend import write_config_key
config_path = get_hermes_home() / "config.yaml"
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(config_path.parent)
- atomic_roundtrip_yaml_update(config_path, "display.personality", name)
+ write_config_key(config_path, "display.personality", name)
try:
os.chmod(config_path, 0o600)
except (OSError, NotImplementedError):
diff --git a/hermes_cli/profile_channels.py b/hermes_cli/profile_channels.py
index ac04c4a71a..cf5d7e7ce2 100644
--- a/hermes_cli/profile_channels.py
+++ b/hermes_cli/profile_channels.py
@@ -269,10 +269,11 @@ def _shared_adapters_active(source_dir: Optional[Path], shared: dict[str, tuple[
"""Shared-prefix platforms the SOURCE runs as a channel: explicitly enabled in its config.yaml, or
auto-enabled by a complete credential set in its ``.env`` and not explicitly disabled — the same
gate ``gateway.platform_env.EnvCred`` applies at gateway start."""
+ from hermes_cli.config_backend import config_exists
if source_dir is None:
return set(shared) # no source to consult: the historical (strip) behaviour
raw: dict = {}
- if (source_dir / "config.yaml").is_file():
+ if config_exists(source_dir / "config.yaml"):
from hermes_cli.config import read_user_config_raw
with contextlib.suppress(Exception):
raw = read_user_config_raw(source_dir / "config.yaml") or {}
@@ -386,7 +387,8 @@ def _channel_config_paths(raw: dict, platforms: Iterable[str]) -> list[tuple[str
def strip_channel_config(config_path: Path, index: Optional[ChannelKeyIndex] = None) -> list[str]:
"""Remove platform sections from a raw ``config.yaml`` in place. Returns the dotted paths removed."""
- if not config_path.is_file():
+ from hermes_cli.config_backend import config_exists
+ if not config_exists(config_path):
return []
from hermes_cli.config import atomic_config_replace, read_user_config_raw
index = index or ChannelKeyIndex()
@@ -450,6 +452,7 @@ def strip_channel_settings(profile_dir: Path, *, include_state: bool, source_dir
def channel_platforms_configured(profile_dir: Path) -> list[str]:
"""Platform ids with any channel setting in ``profile_dir`` (.env keys or config.yaml sections) —
what a channel-less clone of it leaves behind. Pure read, in ``profile_dir``'s plugin scope."""
+ from hermes_cli.config_backend import config_exists
index = ChannelKeyIndex(profile_dir)
found: set[str] = set()
env_path = profile_dir / ".env"
@@ -460,7 +463,7 @@ def channel_platforms_configured(profile_dir: Path) -> list[str]:
if platform and platform != GATEWAY_POLICY_ID:
found.add(platform)
config_path = profile_dir / "config.yaml"
- if config_path.is_file():
+ if config_exists(config_path):
from hermes_cli.config import read_user_config_raw
raw = read_user_config_raw(config_path)
for path in _channel_config_paths(raw, index.platforms):
@@ -499,8 +502,9 @@ def clone_channels_refusal(source_dir: Path, source_label: str) -> Optional[str]
def _config_platform_tokens(config_path: Path) -> dict[str, str]:
"""``{platform: token}`` from ``platforms.
.token|api_key`` (both nesting spellings)."""
+ from hermes_cli.config_backend import config_exists
tokens: dict[str, str] = {}
- if not config_path.is_file():
+ if not config_exists(config_path):
return tokens
from hermes_cli.config import read_user_config_raw
raw = read_user_config_raw(config_path)
diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py
index e2b63be025..007b2f4196 100644
--- a/hermes_cli/profile_distribution.py
+++ b/hermes_cli/profile_distribution.py
@@ -559,7 +559,7 @@ def _copy_dist_payload(staged: Path, target: Path, manifest: DistributionManifes
# _replace_entry unlinks first so copy2 cannot write through a symlinked .env.EXAMPLE.
_replace_entry(src, target / ENV_EXAMPLE_FILENAME)
continue
- if name == "config.yaml" and preserve_config and (target / "config.yaml").exists():
+ if name == "config.yaml" and preserve_config and (target / "config.yaml").exists(): # config-reader: ok — file tooling
continue
if _merges_per_root(src, rel_parts):
_merge_dir(src, _real_dir(target, rel_parts), rel_parts)
diff --git a/hermes_cli/profile_memory_config.py b/hermes_cli/profile_memory_config.py
index 36bb099b09..c3e0da2354 100644
--- a/hermes_cli/profile_memory_config.py
+++ b/hermes_cli/profile_memory_config.py
@@ -63,9 +63,9 @@ def clone_memory_provider_config(source_dir: Path, profile_dir: Path, provider:
def cloned_memory_provider(profile_dir: Path) -> Optional[str]:
"""Name of the external provider whose config *profile_dir* now carries, for the CLI notice."""
- from hermes_cli.profiles import _load_yaml_dict
+ from hermes_cli.profiles import _load_config_dict
- provider = active_memory_provider(_load_yaml_dict(profile_dir / "config.yaml"))
+ provider = active_memory_provider(_load_config_dict(profile_dir))
if provider and ((profile_dir / provider).is_dir() or (profile_dir / f"{provider}.json").is_file()):
return provider
return None
diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py
index 822b9a3de5..a8eead5a0b 100644
--- a/hermes_cli/profiles.py
+++ b/hermes_cli/profiles.py
@@ -585,7 +585,8 @@ def remove_wrapper_script(name: str) -> bool:
def _migrate_profile_config_if_outdated(profile_dir: Path) -> None:
"""Migrate a copied config.yaml to the current schema (non-interactive, scoped to the new
profile); otherwise the first desktop/doctor view shows a scary ``v0 -> latest`` warning."""
- if not (profile_dir / "config.yaml").exists():
+ from hermes_cli.config_backend import config_exists
+ if not config_exists(profile_dir / "config.yaml"):
return
# Creation must not fail over an unmigratable old config; `hermes doctor --fix` surfaces
# the detailed error in the target profile.
@@ -705,6 +706,19 @@ def _load_yaml_dict(path: Path) -> Optional[dict]:
return data if isinstance(data, dict) else None
+def _load_config_dict(profile_dir: Path) -> Optional[dict]:
+ """:func:`_load_yaml_dict` for a profile's config.yaml, read through the config backend."""
+ from hermes_cli.config_backend import config_exists, read_config_doc
+ path = profile_dir / "config.yaml"
+ if not config_exists(path):
+ return None
+ try:
+ data = read_config_doc(path) or {}
+ except Exception:
+ return None
+ return data if isinstance(data, dict) else None
+
+
# (path, kind) -> (file signature, the small derived value). `list_profiles` re-reads three YAML
# files PER PROFILE, and it is the shared body of `GET /api/profiles` and `profiles.list`, which the
# Bots roster polls every 5s per connection — so an installer-seeded config.yaml (the annotated
@@ -755,8 +769,9 @@ def _read_distribution_meta(profile_dir: Path) -> tuple:
def _read_config_model(profile_dir: Path) -> tuple:
"""Read model/provider from a profile's config.yaml. Returns (model, provider)."""
+ from hermes_cli.config_backend import config_exists
config_path = profile_dir / "config.yaml"
- if not config_path.exists():
+ if not config_exists(config_path):
return None, None
def _read() -> tuple:
@@ -778,15 +793,16 @@ def _read_config_model(profile_dir: Path) -> tuple:
def _seed_model_config(profile_dir: Path) -> None:
"""Copy (not link) the active profile's model block into a fresh profile so it is usable;
profiles stay independent islands afterwards."""
+ from hermes_cli.config_backend import config_exists
config_path = profile_dir / "config.yaml"
- if config_path.exists():
+ if config_exists(config_path):
return
with contextlib.suppress(Exception): # creation must not fail over this; `hermes model` sets it later
from hermes_constants import get_hermes_home
from hermes_cli.config import atomic_config_write, read_user_config_raw
from hermes_cli.profile_model_seed import launch_model_seed
source = get_hermes_home() / "config.yaml"
- seed = launch_model_seed(read_user_config_raw(source)) if source.is_file() else {}
+ seed = launch_model_seed(read_user_config_raw(source)) if config_exists(source) else {}
if seed:
atomic_config_write(config_path, seed)
@@ -1068,13 +1084,13 @@ def profile_is_standalone(home: Path) -> bool:
never standalone — it IS the host — and warns once per process if the key is set there."""
global _STANDALONE_WARNED
from hermes_yaml import YAMLError
- from utils import file_signature
+ from hermes_cli.config_backend import config_version
home = Path(home)
cfg_path = home / "config.yaml"
key = str(home)
try:
- signature = file_signature(cfg_path.stat())
+ signature = config_version(cfg_path)
except FileNotFoundError:
signature = None
except OSError as exc:
@@ -1379,8 +1395,7 @@ def _bootstrap_profile_dir(profile_dir: Path, source_dir: Optional[Path],
_clone_file(source_dir, profile_dir, relpath)
_clone_plugins(source_dir, profile_dir)
from hermes_cli.profile_memory_config import active_memory_provider, clone_memory_provider_config
- clone_memory_provider_config(source_dir, profile_dir,
- active_memory_provider(_load_yaml_dict(source_dir / "config.yaml")))
+ clone_memory_provider_config(source_dir, profile_dir, active_memory_provider(_load_config_dict(source_dir)))
if sync_imports: # a plain file at the profile root, written by the agent-importers plugin
_clone_file(source_dir, profile_dir, "import-sync.json")
diff --git a/hermes_cli/send_cmd.py b/hermes_cli/send_cmd.py
index 80739e78d6..8af4e37903 100644
--- a/hermes_cli/send_cmd.py
+++ b/hermes_cli/send_cmd.py
@@ -156,6 +156,7 @@ def _load_hermes_env() -> None:
the profile's external secret sources over it — so it is authoritative as-is; replaying raw
``.env`` over it would let a stale user value beat the secret-manager one for this request.
"""
+ from hermes_cli.config_backend import config_exists
import os
try:
from hermes_cli.config import get_hermes_home
@@ -179,7 +180,7 @@ def _load_hermes_env() -> None:
# Bridge top-level scalars the user (or the managed layer) actually wrote — never DEFAULT_CONFIG —
# into the environment, without overriding existing values.
config_path = home / "config.yaml"
- if not config_path.exists():
+ if not config_exists(config_path):
return
try:
from hermes_cli.config_effective import load_user_config_effective
diff --git a/hermes_cli/update_channel.py b/hermes_cli/update_channel.py
index a85dddf273..2aa8e6a00f 100644
--- a/hermes_cli/update_channel.py
+++ b/hermes_cli/update_channel.py
@@ -322,7 +322,7 @@ def _write_channel_record_locked(sha16: str, path: str, channel: str,
the dotted writer would otherwise turn a scalar into a mapping and
destroy whatever the user had there.
"""
- from utils import atomic_roundtrip_yaml_update
+ from hermes_cli.config_backend import write_config_key
from hermes_cli.config import require_readable_config_before_write
existing = require_readable_config_before_write(config_path)
@@ -338,7 +338,7 @@ def _write_channel_record_locked(sha16: str, path: str, channel: str,
new_record = dict(record) if isinstance(record, dict) else {}
new_record["path"] = path # DATA, for humans + doctor GC
new_record["channel"] = channel
- atomic_roundtrip_yaml_update(config_path, f"update.installs.{sha16}", new_record)
+ write_config_key(config_path, f"update.installs.{sha16}", new_record)
return True
diff --git a/hermes_cli/update_cmd_config.py b/hermes_cli/update_cmd_config.py
index bc0993ce41..ff14ce898d 100644
--- a/hermes_cli/update_cmd_config.py
+++ b/hermes_cli/update_cmd_config.py
@@ -52,7 +52,7 @@ def _migrate_sibling_profile_configs(notes: dict[str, list[str]] | None = None)
from hermes_cli.backup import _sibling_profile_homes
active_home = Path(get_process_hermes_home())
for name, profile_home in _sibling_profile_homes(active_home):
- if not (profile_home / "config.yaml").is_file():
+ if not (profile_home / "config.yaml").is_file(): # config-reader: ok — profile directory discovery marker
continue # profile never configured — nothing to migrate
token = set_hermes_home_override(profile_home)
try:
diff --git a/hermes_cli/web_routers/analytics.py b/hermes_cli/web_routers/analytics.py
index 4c6d5e388e..253e5bb85c 100644
--- a/hermes_cli/web_routers/analytics.py
+++ b/hermes_cli/web_routers/analytics.py
@@ -41,11 +41,15 @@ async def get_config_raw(profile: Optional[str] = None):
process's own profile, which is wrong under the global profile switcher.
"""
def _run():
+ from hermes_cli.config_backend import supports_file_tooling
+ if not supports_file_tooling():
+ # The raw editor shows the file's bytes; a non-file backend has no file.
+ raise HTTPException(status_code=409, detail="This config backend has no raw config file")
with _profile_scope(profile):
path = get_config_path()
- if not path.exists():
+ if not path.exists(): # config-reader: ok — file tooling (gated above)
return {"yaml": "", "path": str(path)}
- return {"yaml": path.read_text(encoding="utf-8-sig"), "path": str(path)}
+ return {"yaml": path.read_text(encoding="utf-8-sig"), "path": str(path)} # config-reader: ok — file tooling (gated above)
return await asyncio.to_thread(_run)
diff --git a/hermes_cli/xai_retirement.py b/hermes_cli/xai_retirement.py
index 91c861a6f5..8b62aeccbf 100644
--- a/hermes_cli/xai_retirement.py
+++ b/hermes_cli/xai_retirement.py
@@ -131,8 +131,13 @@ def apply_migration(
Unless ``backup=False`` a copy goes to ``backups/config/`` (reason ``pre-migrate-xai``).
"""
from ruamel.yaml import YAML # local import — avoid hard dep at module load
+ from hermes_cli.config_backend import ConfigBackendUnavailable, supports_file_tooling
+ if not supports_file_tooling():
+ # A type-literal-preserving rewrite of the file itself; a non-file backend has no file.
+ raise ConfigBackendUnavailable("the xAI model migration rewrites the local config.yaml, and "
+ "this config backend has none")
config_path = Path(config_path)
- if not config_path.exists():
+ if not config_path.exists(): # config-reader: ok — file tooling, gated on supports_file_tooling()
raise FileNotFoundError(config_path)
unchanged = ApplyResult(file_path=config_path, backup_path=None, issues_resolved=[], config_changed=False)
if not issues:
@@ -142,7 +147,7 @@ def apply_migration(
yaml = YAML(typ="rt")
yaml.width = ROUNDTRIP_YAML_WIDTH
yaml.preserve_quotes = True
- with config_path.open("r", encoding="utf-8-sig") as fh:
+ with config_path.open("r", encoding="utf-8-sig") as fh: # config-reader: ok — file tooling (gated above)
doc = yaml.load(fh)
if doc is None:
return unchanged
@@ -175,5 +180,5 @@ def apply_migration(
# 0640 / container installs; a root-run migration must not flip ownership).
buf = io.StringIO()
yaml.dump(doc, buf)
- atomic_write_text(config_path, buf.getvalue(), preserve_mode=True)
+ atomic_write_text(config_path, buf.getvalue(), preserve_mode=True) # config-reader: ok — file tooling (gated above)
return ApplyResult(file_path=config_path, backup_path=backup_path, issues_resolved=resolved, config_changed=True)
diff --git a/hermes_logging.py b/hermes_logging.py
index fa86e3aa94..53f118630c 100644
--- a/hermes_logging.py
+++ b/hermes_logging.py
@@ -1030,12 +1030,9 @@ def _read_logging_config():
from hermes_cli.config_effective import load_user_config_effective
cfg = load_user_config_effective(get_config_path())
except Exception:
- from utils import fast_safe_load
+ from hermes_cli.config_backend import config_exists, read_config_doc
config_path = get_config_path()
- cfg = {}
- if config_path.exists():
- with open(config_path, "r", encoding="utf-8-sig") as f:
- cfg = fast_safe_load(f) or {}
+ cfg = (read_config_doc(config_path) or {}) if config_exists(config_path) else {}
if not cfg:
return (None, None, None)
log_cfg = cfg.get("logging", {})
diff --git a/hermes_time.py b/hermes_time.py
index ad6bf842ae..b8bf20e3f5 100644
--- a/hermes_time.py
+++ b/hermes_time.py
@@ -89,14 +89,15 @@ def _resolve_timezone_name() -> str:
# Prefer the shared cached effective-config loader (mtime-keyed + libyaml, managed overlay
# included so an administrator can pin ``timezone``): a direct safe_load of a large
# config.yaml costs ~100 ms and this ran inside the FIRST system prompt build. The bare
- # parse is the stdlib-safe fallback for bootstrap consumers without hermes_cli importable.
+ # parse (through the dependency-free config backend module) is the fallback when the
+ # effective loader cannot be imported.
try:
from hermes_cli.config_effective import load_user_config_effective
cfg = load_user_config_effective(get_config_path())
except Exception:
- import hermes_yaml as yaml
+ from hermes_cli.config_backend import config_exists, read_config_doc
config_path = get_config_path()
- cfg = (yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}) if config_path.exists() else {}
+ cfg = (read_config_doc(config_path) or {}) if config_exists(config_path) else {}
if cfg:
tz_cfg = cfg.get("timezone", "")
if isinstance(tz_cfg, str) and tz_cfg.strip():
diff --git a/model_tools.py b/model_tools.py
index 97f2987a7b..5010146333 100644
--- a/model_tools.py
+++ b/model_tools.py
@@ -23,7 +23,6 @@ from tools.registry import _MAX_TOOL_ERROR_CHARS as _TOOL_ERROR_MAX_LEN
from toolsets import resolve_toolset, validate_toolset
from tools.arg_coercion import coerce_tool_args
from tools.todo_tool import TODO_LEGACY_ALIASES, TODO_SCHEMA
-from utils import file_signature
logger = logging.getLogger(__name__)
@@ -266,8 +265,8 @@ def _tool_defs_cache_key(
return None
try:
from hermes_cli.config import get_config_path
- cfg_stat = get_config_path().stat()
- cfg_fp = file_signature(cfg_stat)
+ from hermes_cli.config_backend import config_version
+ cfg_fp = config_version(get_config_path())
except (FileNotFoundError, OSError, ImportError):
cfg_fp = None
return (
diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py
index 7e152298a1..635a12722b 100644
--- a/plugins/platforms/telegram/adapter.py
+++ b/plugins/platforms/telegram/adapter.py
@@ -2589,10 +2589,11 @@ class TelegramAdapter(TelegramHeldInboundMixin, TelegramTopicRoutingMixin, BaseP
def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int, replace_existing: bool = False) -> None:
"""Save a newly created thread_id back into config.yaml so it survives restarts."""
+ from hermes_cli.config_backend import config_exists
try:
from hermes_constants import get_hermes_home
config_path = get_hermes_home() / "config.yaml"
- if not config_path.exists():
+ if not config_exists(config_path):
logger.warning("[%s] Config file not found at %s, cannot persist thread_id", self.name, config_path)
return
from hermes_cli.config import atomic_config_write, read_user_config_raw
diff --git a/scripts/check_config_yaml_readers.py b/scripts/check_config_yaml_readers.py
new file mode 100644
index 0000000000..3f9e36eb6b
--- /dev/null
+++ b/scripts/check_config_yaml_readers.py
@@ -0,0 +1,205 @@
+#!/usr/bin/env python3
+"""Fail when a user ``config.yaml`` is read by anything but the config backend.
+
+Every read of ``/config.yaml`` must go through ``hermes_cli.config_backend``
+(``get_config_backend()`` / the path helpers there) so a non-file backend (design §4.1, D10) sees
+every reader. A direct ``open`` / ``read_text`` / ``yaml.safe_load`` / ``fast_safe_load`` /
+``load_yaml_file_readonly`` / ``stat`` of a config path silently keeps reading the local file —
+the reader twin of the #92554 writer class, which regrew each time a new caller picked the raw
+primitive again.
+
+Flags, in the scanned trees:
+
+* ``open(p)``, ``p.open()``, ``p.read_text()``, ``p.read_bytes()``, ``p.stat()``,
+ ``p.exists()``, ``p.is_file()``, ``os.stat(p)``, ``os.path.exists(p)``, ``os.path.isfile(p)``,
+ ``os.path.getmtime(p)``, ``load_yaml_file_readonly(p)``, ``safe_load(p)``,
+ ``fast_safe_load(p)``, ``yaml.load(p)`` and any ``*_load_yaml*`` / ``*_read_yaml*`` helper
+ call, where ``p`` is a config path: an expression naming ``config.yaml``,
+ any ``*config_path()`` call (``get_config_path()``, ``_active_config_path()``), or a local name / parameter bound to one
+ (``cfg_path = home / "config.yaml"`` then ``open(cfg_path)``);
+* ``atomic_roundtrip_yaml_update`` / ``atomic_roundtrip_yaml_save`` / ``atomic_write_text`` of a
+ config path (the bypass writers; route them through ``write_config_document`` /
+ ``write_config_key``).
+
+Existence checks count: for a backend whose user layer is not a local file, "no local
+config.yaml" must never mean "use defaults" (design §4.2) — ``config_exists()`` asks the backend.
+
+Suppress a true false positive with ``# config-reader: ok — `` on the call's line.
+
+Usage: python3 scripts/check_config_yaml_readers.py [paths...]
+"""
+from __future__ import annotations
+
+import ast
+import re
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+DEFAULT_TREES = (
+ "hermes_cli", "agent", "gateway", "tui_gateway", "cron", "plugins", "tools", "acp_adapter",
+ "cli.py", "utils.py", "hermes_constants.py", "hermes_logging.py", "hermes_time.py", "run_agent.py",
+ "model_tools.py", "batch_runner.py")
+# The backend module itself, and the on-disk primitives it wraps.
+ALLOWED_FILES = {ROOT / "hermes_cli" / "config_backend.py", ROOT / "utils.py"}
+# Modules whose ``config_path`` / ``path`` names are their OWN config file, never a hermes
+# config.yaml (the name heuristic would flag every line of them).
+FOREIGN_CONFIG_FILES = {
+ "plugins/memory/honcho": "Honcho's own honcho.json",
+ "plugins/memory/openviking": "OpenViking's own ovcli config",
+ "agent/proxy_sources/iron_proxy.py": "iron-proxy's proxy.yaml",
+}
+SUPPRESS = "# config-reader: ok"
+
+# An expression that evaluates to a user config.yaml path.
+CONFIG_PATH_EXPR_RE = re.compile(r"""["']config\.yaml["']|\w*config_path\(\)""")
+# Parameters that conventionally carry the user config path.
+CONFIG_PARAM_NAMES = {"config_path", "cfg_path", "config_yaml_path"}
+PATH_METHOD_READS = {"open", "read_text", "read_bytes", "stat", "exists", "is_file"}
+FUNC_READS = {"exists", "isfile", "open", "load_yaml_file_readonly", "safe_load", "fast_safe_load", "getmtime", "stat"}
+BYPASS_WRITERS = {"atomic_roundtrip_yaml_update", "atomic_roundtrip_yaml_save", "atomic_write_text"}
+YAML_HELPER_RE = re.compile(r"(load|read)_yaml|yaml_(load|read)")
+
+
+def _func_name(node: ast.AST) -> str | None:
+ if isinstance(node, ast.Attribute):
+ return node.attr
+ if isinstance(node, ast.Name):
+ return node.id
+ return None
+
+
+def _scope_nodes(scope: ast.AST):
+ """Nodes of *scope* without descending into nested function/class bodies."""
+ stack = list(ast.iter_child_nodes(scope))
+ while stack:
+ node = stack.pop()
+ yield node
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
+ stack.extend(ast.iter_child_nodes(node))
+
+
+class _Scanner:
+ def __init__(self, path: Path):
+ self.path = path
+ self.src = path.read_text(encoding="utf-8-sig")
+ self.lines = re.split(r"\r\n|\r|\n", self.src) # the tokenizer's line breaks, not splitlines()'s
+ self.blines = [line.encode("utf-8") for line in self.lines]
+ self.rel = path.relative_to(ROOT).as_posix()
+ self.problems: list[str] = []
+
+ def seg(self, node: ast.AST) -> str:
+ # ast.get_source_segment re-splits the whole file per call (quadratic on config.py).
+ start, end = getattr(node, "lineno", None), getattr(node, "end_lineno", None)
+ if start is None or end is None:
+ return ""
+ # col offsets are UTF-8 byte offsets.
+ c0, c1 = getattr(node, "col_offset"), getattr(node, "end_col_offset")
+ b = self.blines
+ if start == end:
+ return b[start - 1][c0:c1].decode("utf-8", "replace")
+ return b"\n".join([b[start - 1][c0:], *b[start:end - 1], b[end - 1][:c1]]).decode("utf-8", "replace")
+
+ def is_config_path(self, node: ast.AST, names: set[str]) -> bool:
+ if isinstance(node, ast.Name) and node.id in names:
+ return True
+ if isinstance(node, ast.Call) and _func_name(node.func) in {"Path", "str", "expanduser", "resolve"}:
+ inner = node.args[0] if node.args else (node.func.value if isinstance(node.func, ast.Attribute) else None)
+ if inner is not None and self.is_config_path(inner, names):
+ return True
+ if isinstance(node, ast.BoolOp):
+ return any(self.is_config_path(v, names) for v in node.values)
+ return bool(CONFIG_PATH_EXPR_RE.search(self.seg(node)))
+
+ def config_names(self, scope: ast.AST, inherited: set[str]) -> set[str]:
+ names = set(inherited)
+ if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ for a in (*scope.args.posonlyargs, *scope.args.args, *scope.args.kwonlyargs):
+ if a.arg in CONFIG_PARAM_NAMES:
+ names.add(a.arg)
+ # Two passes so `a = home / "config.yaml"; b = a` binds b too.
+ for _ in range(2):
+ for node in _scope_nodes(scope):
+ targets: list = []
+ if isinstance(node, ast.Assign):
+ targets, value = node.targets, node.value
+ elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)) and node.value is not None:
+ targets, value = [node.target], node.value
+ else:
+ continue
+ if self.is_config_path(value, names):
+ names.update(t.id for t in targets if isinstance(t, ast.Name))
+ return names
+
+ def flag(self, node: ast.Call, why: str) -> None:
+ if SUPPRESS in self.lines[node.lineno - 1]:
+ return
+ self.problems.append(f"{self.rel}:{node.lineno}: {why} — go through hermes_cli.config_backend")
+
+ def check_call(self, node: ast.Call, names: set[str]) -> None:
+ name = _func_name(node.func)
+ if name is None:
+ return
+ receiver = node.func.value if isinstance(node.func, ast.Attribute) else None
+ first = node.args[0] if node.args else None
+ if name in BYPASS_WRITERS and first is not None and self.is_config_path(first, names):
+ self.flag(node, f"bypass write of a config path ({self.seg(first)})")
+ return
+ if name in PATH_METHOD_READS and receiver is not None and self.is_config_path(receiver, names):
+ self.flag(node, f"direct {name}() of a config path ({self.seg(receiver)})")
+ return
+ is_reader = name in FUNC_READS or YAML_HELPER_RE.search(name) is not None
+ if name == "load" and receiver is not None and "yaml" in self.seg(receiver).lower():
+ is_reader = True
+ if is_reader and first is not None and (receiver is None or name != "open") and self.is_config_path(first, names):
+ self.flag(node, f"direct {name}() of a config path ({self.seg(first)[:60]})")
+
+ def scan(self) -> list[str]:
+ try:
+ tree = ast.parse(self.src)
+ except SyntaxError:
+ return []
+
+ def walk(scope: ast.AST, inherited: set[str]) -> None:
+ names = self.config_names(scope, inherited)
+ for node in _scope_nodes(scope):
+ if isinstance(node, ast.Call):
+ self.check_call(node, names)
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
+ walk(node, names)
+
+ walk(tree, set())
+ return self.problems
+
+
+def scan_file(path: Path) -> list[str]:
+ return _Scanner(path).scan()
+
+
+def main(argv: list[str]) -> int:
+ targets = [ROOT / a for a in argv] or [ROOT / t for t in DEFAULT_TREES]
+ files: list[Path] = []
+ for t in targets:
+ if t.is_dir():
+ files.extend(p for p in t.rglob("*.py") if "node_modules" not in p.parts and "tests" not in p.parts)
+ elif t.suffix == ".py" and t.exists():
+ files.append(t)
+ problems: list[str] = []
+ for f in sorted(set(files)):
+ if f.resolve() in ALLOWED_FILES:
+ continue
+ rel = f.resolve().relative_to(ROOT).as_posix() if f.resolve().is_relative_to(ROOT) else ""
+ if any(rel == p or rel.startswith(p + "/") for p in FOREIGN_CONFIG_FILES):
+ continue
+ problems.extend(scan_file(f))
+ if problems:
+ print("config.yaml must only be read through hermes_cli.config_backend:", file=sys.stderr)
+ for p in problems:
+ print(f" {p}", file=sys.stderr)
+ return 1
+ print(f"check_config_yaml_readers: OK ({len(files)} files)")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
diff --git a/tests/agent/test_skill_utils.py b/tests/agent/test_skill_utils.py
index e8809503d0..6cffa328d0 100644
--- a/tests/agent/test_skill_utils.py
+++ b/tests/agent/test_skill_utils.py
@@ -48,18 +48,20 @@ skills:
""".strip(),
encoding="utf-8",
)
+ import utils
parse_count = 0
- real_yaml_load = skill_utils.yaml_load
+ real_load = utils.fast_safe_load
- def counting_yaml_load(text):
+ def counting_load(stream):
nonlocal parse_count
parse_count += 1
- return real_yaml_load(text)
+ return real_load(stream)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
skill_utils._external_dirs_cache_clear()
getattr(skill_utils, "_raw_config_cache_clear", lambda: None)()
- monkeypatch.setattr(skill_utils, "yaml_load", counting_yaml_load)
+ # The file config backend parses through utils.fast_safe_load.
+ monkeypatch.setattr(utils, "fast_safe_load", counting_load)
assert get_disabled_skill_names() == {"hidden-skill"}
assert get_external_skills_dirs() == [external.resolve()]
diff --git a/tests/hermes_cli/test_config_backend.py b/tests/hermes_cli/test_config_backend.py
new file mode 100644
index 0000000000..4e74c99d2c
--- /dev/null
+++ b/tests/hermes_cli/test_config_backend.py
@@ -0,0 +1,114 @@
+"""The ConfigBackend seam (config-config design §4.1, D10/D11) and its reader gate."""
+
+import sys
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from hermes_cli import config_backend as cb
+
+REPO = Path(__file__).resolve().parents[2]
+
+
+@pytest.fixture
+def home(tmp_path, monkeypatch):
+ monkeypatch.delenv(cb.BACKEND_ENV, raising=False)
+ h = tmp_path / "home"
+ h.mkdir()
+ return h
+
+
+class TestSelection:
+
+ def test_default_is_file(self, home):
+ assert cb.get_config_backend().name == "file"
+ assert cb.supports_file_tooling() is True
+
+ @pytest.mark.parametrize("value", ["remote", "bogus"])
+ def test_unavailable_backend_fails_closed(self, home, monkeypatch, value):
+ # No config value can select the backend, and an unknown one never falls back to the file.
+ monkeypatch.setenv(cb.BACKEND_ENV, value)
+ with pytest.raises(cb.ConfigBackendUnavailable):
+ cb.get_config_backend()
+ with pytest.raises(cb.ConfigBackendUnavailable):
+ cb.read_config_doc(home / "config.yaml")
+
+ def test_explicit_non_config_file_ignores_backend(self, home, monkeypatch):
+ other = home / "import-source.yaml"
+ other.write_text("a: 1\n", encoding="utf-8")
+ monkeypatch.setenv(cb.BACKEND_ENV, "remote")
+ assert cb.read_config_doc(other) == {"a": 1}
+
+
+class TestFileBackend:
+
+ def test_missing(self, home):
+ path = home / "config.yaml"
+ assert cb.config_exists(path) is False
+ with pytest.raises(FileNotFoundError):
+ cb.config_version(path)
+ with pytest.raises(FileNotFoundError):
+ cb.read_config_doc(path)
+
+ def test_read_version_and_bom(self, home):
+ path = home / "config.yaml"
+ path.write_bytes("\ufeffmodel:\n default: x\n".encode("utf-8"))
+ assert cb.config_exists(path)
+ assert cb.read_config_doc(path) == {"model": {"default": "x"}}
+ assert cb.read_config_doc_readonly(path) == {"model": {"default": "x"}}
+ v1 = cb.config_version(path)
+ path.write_text("model:\n default: longer-value\n", encoding="utf-8")
+ assert cb.config_version(path) != v1
+
+ def test_writes_preserve_comments(self, home):
+ path = home / "config.yaml"
+ path.write_text("# keep me\nmodel:\n default: a # inline\n", encoding="utf-8")
+ cb.write_config_key(path, "model.default", "b")
+ cb.write_config_key(path, "display.personality", "kawaii")
+ text = path.read_text(encoding="utf-8")
+ assert "# keep me" in text and "# inline" in text
+ assert cb.read_config_doc(path) == {"model": {"default": "b"}, "display": {"personality": "kawaii"}}
+ cb.write_config_document(path, {"model": {"default": "c"}})
+ assert "# keep me" in path.read_text(encoding="utf-8")
+ assert cb.read_config_doc(path) == {"model": {"default": "c"}}
+
+
+class TestReaderGate:
+
+ def _guard(self):
+ sys.path.insert(0, str(REPO / "scripts"))
+ try:
+ import check_config_yaml_readers as guard
+ finally:
+ sys.path.pop(0)
+ return guard
+
+ def test_flags_direct_readers(self, tmp_path):
+ guard = self._guard()
+ bad = tmp_path / "hermes_cli" / "bad_reader.py"
+ bad.parent.mkdir()
+ bad.write_text(
+ "import yaml\n"
+ "def a(home):\n p = home / 'config.yaml'\n return yaml.safe_load(p.read_text())\n"
+ "def b(config_path):\n return config_path.exists()\n"
+ "def c(other):\n return other.read_text()\n"
+ "def d(cfg_path):\n return cfg_path.stat() # config-reader: ok — test\n"
+ "def e():\n from utils import atomic_roundtrip_yaml_update\n"
+ " atomic_roundtrip_yaml_update(get_config_path(), 'a', 1)\n",
+ encoding="utf-8")
+ with patch.object(guard, "ROOT", tmp_path):
+ problems = guard.scan_file(bad)
+ assert sorted(int(p.split(":")[1]) for p in problems) == [4, 6, 13], problems
+
+ def test_backend_calls_are_clean(self, tmp_path):
+ guard = self._guard()
+ good = tmp_path / "hermes_cli" / "good_reader.py"
+ good.parent.mkdir()
+ good.write_text(
+ "from hermes_cli.config_backend import config_exists, read_config_doc\n"
+ "def a(home):\n p = home / 'config.yaml'\n"
+ " return read_config_doc(p) if config_exists(p) else {}\n",
+ encoding="utf-8")
+ with patch.object(guard, "ROOT", tmp_path):
+ assert guard.scan_file(good) == []
diff --git a/tests/hermes_cli/test_config_save_integrity.py b/tests/hermes_cli/test_config_save_integrity.py
index 71968f15ec..de2d57c6cd 100644
--- a/tests/hermes_cli/test_config_save_integrity.py
+++ b/tests/hermes_cli/test_config_save_integrity.py
@@ -38,7 +38,8 @@ class _ReadFaults:
"""Counts parses of config.yaml and fails the chosen one with a transient EMFILE (intact file)."""
def __init__(self, monkeypatch, path):
- self.path, self.count, self.fail_at, real = str(path), 0, 0, config_mod.fast_safe_load
+ import utils
+ self.path, self.count, self.fail_at, real = str(path), 0, 0, utils.fast_safe_load
def flaky(stream):
if getattr(stream, "name", None) == self.path:
@@ -46,7 +47,8 @@ class _ReadFaults:
if self.count == self.fail_at:
raise OSError(errno.EMFILE, "Too many open files")
return real(stream)
- monkeypatch.setattr(config_mod, "fast_safe_load", flaky)
+ # Every config read parses through the file config backend, which calls utils.fast_safe_load.
+ monkeypatch.setattr(utils, "fast_safe_load", flaky)
def arm(self, fail_at=0):
self.count, self.fail_at = 0, fail_at
@@ -157,7 +159,7 @@ def test_unreadable_config_serves_one_cached_fallback_until_it_reads(home, monke
once, not per call: ~250x slower loads before), and the first load once the file opens again
reads the real file even though its signature never changed."""
import builtins
- from hermes_cli import config_backups
+ from hermes_cli import config_backend, config_backups
from hermes_cli.config_read_errors import FailedConfigRead
path = home / "config.yaml"
_fresh_process(home, _CONFIG)
@@ -170,7 +172,7 @@ def test_unreadable_config_serves_one_cached_fallback_until_it_reads(home, monke
if blocked[0] and str(file) == str(path):
raise OSError(errno.EMFILE, "Too many open files")
return builtins.open(file, *args, **kwargs)
- monkeypatch.setattr(config_mod, "open", guarded_open, raising=False)
+ monkeypatch.setattr(config_backend, "open", guarded_open, raising=False)
monkeypatch.setattr(config_backups, "load_newest_good_backup", lambda p: rebuilds.append(p) or real_backup(p))
for _ in range(5):
diff --git a/tools/bot_mode_probe.py b/tools/bot_mode_probe.py
index a67db2addb..6392ce3b89 100644
--- a/tools/bot_mode_probe.py
+++ b/tools/bot_mode_probe.py
@@ -209,7 +209,9 @@ def _peers(root: Path) -> list[str]:
"""Registered peer gateway names (``hermes peer``) from config.yaml, read
directly (no config-loader import; the section is absent on most installs). Never raises."""
def _names() -> list[str]:
- peers = (_read_yaml_dict(root / "config.yaml", "bot_peers") or {}).get("bot_peers")
+ from hermes_cli.config_backend import read_config_doc_readonly
+ doc = read_config_doc_readonly(root / "config.yaml")
+ peers = doc.get("bot_peers") if isinstance(doc, dict) else None
return sorted(str(n) for n in peers if str(n).strip()) if isinstance(peers, dict) else []
return _swallow(_names, [])
diff --git a/tools/terminal_scope.py b/tools/terminal_scope.py
index 270a47a06b..a9110eb6d2 100644
--- a/tools/terminal_scope.py
+++ b/tools/terminal_scope.py
@@ -127,6 +127,8 @@ def build_profile_terminal_scope(
closes. It sits where the process env sits in the standalone bridge — explicit YAML keys
still win (``apply_terminal_config_to_env``).
"""
+ from hermes_cli.config_backend import config_exists
+
home = Path(hermes_home)
scope = default_terminal_scope()
default_image = scope.get("TERMINAL_DOCKER_IMAGE")
@@ -159,15 +161,15 @@ def build_profile_terminal_scope(
# "unparseable" into {}): present-but-unparseable must fail closed.
config_path = home / "config.yaml"
try:
- config_exists = config_path.exists()
+ config_exists = config_exists(config_path)
except Exception as exc:
raise TerminalPolicyUnavailable(f"cannot resolve terminal config in {home}: {exc}") from exc
if config_exists:
- from utils import load_yaml_file_readonly
+ from hermes_cli.config_backend import read_config_doc_readonly
try:
# Signature-cached: a scope is rebuilt per routed turn/poll, the file rarely changes.
- raw = load_yaml_file_readonly(config_path)
+ raw = read_config_doc_readonly(config_path)
except Exception as exc:
raise TerminalPolicyUnavailable(f"cannot parse {config_path}: {exc}") from exc
raw_terminal = raw.get("terminal") if isinstance(raw, dict) else None
diff --git a/tools/website_policy.py b/tools/website_policy.py
index d2c24b2066..2f5ebf8e12 100644
--- a/tools/website_policy.py
+++ b/tools/website_policy.py
@@ -78,7 +78,8 @@ def _require_mapping(value: Any, label: str) -> dict[str, Any]:
def _load_policy_config(config_path: Path) -> dict[str, Any]:
- if not config_path.exists():
+ from hermes_cli.config_backend import config_exists, read_config_doc
+ if not config_exists(config_path):
return dict(_DEFAULT_WEBSITE_BLOCKLIST)
try:
import hermes_yaml as yaml
@@ -86,7 +87,7 @@ def _load_policy_config(config_path: Path) -> dict[str, Any]:
logger.debug("ruamel.yaml not installed — website blocklist disabled")
return dict(_DEFAULT_WEBSITE_BLOCKLIST)
try:
- config = yaml.safe_load(config_path.read_text(encoding="utf-8-sig")) or {}
+ config = read_config_doc(config_path) or {}
except yaml.YAMLError as exc:
raise WebsitePolicyError(f"Invalid config YAML at {config_path}: {exc}") from exc
except OSError as exc:
diff --git a/tui_gateway/methods_config.py b/tui_gateway/methods_config.py
index 2ee5564f08..ac34f7c613 100644
--- a/tui_gateway/methods_config.py
+++ b/tui_gateway/methods_config.py
@@ -244,9 +244,11 @@ def _cfg_get_thinking_mode(params):
def _cfg_get_mtime(params):
+ from hermes_cli.config_backend import config_exists, config_version
cfg_path = _hermes_home / "config.yaml"
try:
- mtime = cfg_path.stat().st_mtime if cfg_path.exists() else 0
+ # The client only compares for change; the file backend's version leads with st_mtime_ns.
+ mtime = config_version(cfg_path)[0] / 1_000_000_000 if config_exists(cfg_path) else 0
except Exception:
return {"mtime": 0}
# mcp_rev: hash of the MCP-relevant sections so the poller reloads MCP servers only when
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index e5a7a6193e..7469d779e2 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -28,7 +28,7 @@ from hermes_constants import (
get_hermes_home, get_hermes_home_override, get_process_hermes_home, profile_name_for_home,
reset_hermes_home_override, set_hermes_home_override)
from hermes_cli.env_loader import load_hermes_dotenv
-from utils import file_signature, is_truthy_value
+from utils import is_truthy_value
from hermes_state_ids import new_session_id
from tools.environments.local import hermes_subprocess_env
from agent.fast_mode import STATIC_TIERS
@@ -671,12 +671,13 @@ def _profile_configured_cwd(profile_home: Path | None) -> str | None:
env var (issue #40334). Returns an absolute, existing directory, or None for placeholders / missing /
invalid paths.
"""
+ from hermes_cli.config_backend import config_exists
if profile_home is None:
return None
with contextlib.suppress(Exception):
from hermes_cli.config_effective import load_user_config_effective
p = Path(profile_home) / "config.yaml"
- return _configured_cwd_from_cfg(load_user_config_effective(p)) if p.exists() else None
+ return _configured_cwd_from_cfg(load_user_config_effective(p)) if config_exists(p) else None
return None
@@ -997,15 +998,16 @@ def _load_cfg_raw() -> dict:
expansion applied here would be persisted on the next save). Behavioral reads use :func:`_load_cfg`.
Cache keyed on the resolved path so profiles don't clobber."""
global _cfg_cache, _cfg_sig, _cfg_path
+ from hermes_cli.config_backend import config_exists, config_version
from hermes_cli.config import read_user_config_raw
from hermes_cli.config_read_errors import FailedConfigRead
try:
p = _active_config_path()
- sig = file_signature(p.stat()) if p.exists() else None
+ sig = config_version(p) if config_exists(p) else None
with _cfg_lock:
if _cfg_cache is not None and _cfg_sig == sig and _cfg_path == p:
return copy.deepcopy(_cfg_cache)
- data = read_user_config_raw(p) if p.exists() else {}
+ data = read_user_config_raw(p) if config_exists(p) else {}
except Exception as exc:
return FailedConfigRead(error=exc) # readable as {}, refused by _save_cfg
with _cfg_lock: # cache the RAW config: _save_cfg writes _cfg_cache back to disk
@@ -1027,12 +1029,13 @@ def _load_cfg() -> dict:
def _save_cfg(cfg: dict):
global _cfg_cache, _cfg_sig, _cfg_path
from hermes_cli.config import atomic_config_replace
+ from hermes_cli.config_backend import config_version
path = _active_config_path()
atomic_config_replace(path, cfg)
with _cfg_lock:
_cfg_cache, _cfg_path = copy.deepcopy(cfg), path
try:
- _cfg_sig = file_signature(path.stat())
+ _cfg_sig = config_version(path)
except Exception:
_cfg_sig = None
From 9909c4f7f9599e229566664d7d1fcb74e8b47a07 Mon Sep 17 00:00:00 2001
From: Ben Barclay
Date: Fri, 25 Sep 2026 13:34:18 +1000
Subject: [PATCH 018/114] feat(config): refuse file-copy tools without backend
file tooling; backend-consulted tests
---
hermes_cli/backup.py | 6 ++
hermes_cli/config_backend.py | 8 ++
hermes_cli/profile_distribution.py | 2 +
hermes_cli/profiles.py | 2 +
tests/hermes_cli/test_config_backend.py | 126 ++++++++++++++++++++++++
5 files changed, 144 insertions(+)
diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py
index 3790935578..f85eeebac9 100644
--- a/hermes_cli/backup.py
+++ b/hermes_cli/backup.py
@@ -595,6 +595,8 @@ def run_backup(args) -> bool:
the caller turns False into exit status 1 so a cron/systemd timer never publishes a "successful"
archive that is missing state.db. Hard failures keep raising ``SystemExit``.
"""
+ from hermes_cli.config_backend import require_file_tooling
+ require_file_tooling("`hermes backup`")
hermes_root = get_default_hermes_root()
if not hermes_root.is_dir():
@@ -731,6 +733,8 @@ def _import_member_rel(member: str, prefix: str) -> tuple[str, bool]:
def run_import(args) -> Optional[int]:
"""Restore a Hermes backup; return 1 on damaged archives or incomplete restores."""
+ from hermes_cli.config_backend import require_file_tooling
+ require_file_tooling("`hermes import`")
zip_path = Path(args.zipfile).expanduser().resolve()
if not zip_path.is_file():
@@ -1540,6 +1544,8 @@ def restore_quick_snapshot(
Returns True if at least one file was restored and the listed auth.json
was not refused or skipped.
"""
+ from hermes_cli.config_backend import require_file_tooling
+ require_file_tooling("Snapshot restore")
from gateway.runtime_ownership import OwnershipConflict, exclusive_maintenance
home = hermes_home or get_hermes_home()
try:
diff --git a/hermes_cli/config_backend.py b/hermes_cli/config_backend.py
index 15c4a8264c..87c71c3630 100644
--- a/hermes_cli/config_backend.py
+++ b/hermes_cli/config_backend.py
@@ -217,3 +217,11 @@ def write_config_key(config_path: PathLike, key_path: str, value: Any) -> None:
def supports_file_tooling() -> bool:
"""Gate for tools that copy, edit or back up the config FILE itself (§4.3, §4.7)."""
return get_config_backend().supports_file_tooling()
+
+
+def require_file_tooling(what: str) -> None:
+ """Refuse *what* (a file-copy tool) with a clear message when the backend has no config file."""
+ if not supports_file_tooling():
+ raise ConfigBackendUnavailable(
+ f"{what} copies the local config.yaml, and the {get_config_backend().name!r} config "
+ "backend has none. It is not available with this backend.")
diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py
index 007b2f4196..d488d06fd1 100644
--- a/hermes_cli/profile_distribution.py
+++ b/hermes_cli/profile_distribution.py
@@ -539,6 +539,8 @@ def _copy_dist_payload(staged: Path, target: Path, manifest: DistributionManifes
A top-level owned directory, and an owned container (``_is_container``), is merged per
authored root. ``cron/jobs.json`` is special: it is one multi-record runtime store, so
shipped definitions merge by job id instead of replacing the file."""
+ from hermes_cli.config_backend import require_file_tooling
+ require_file_tooling("Profile distribution install")
target.mkdir(parents=True, exist_ok=True)
entries = list(_owned_entries(staged, manifest))
_refuse_symlinked_targets(target, entries)
diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py
index a8eead5a0b..e4e0fba858 100644
--- a/hermes_cli/profiles.py
+++ b/hermes_cli/profiles.py
@@ -1179,6 +1179,8 @@ def profiles_to_serve(multiplex: bool, *, include_standalone: bool = False,
def _resolve_clone_source(clone_from: Optional[str]) -> Path:
"""Directory to clone from: the named profile, or the active profile when ``None``."""
+ from hermes_cli.config_backend import require_file_tooling
+ require_file_tooling("Profile clone")
if clone_from is None:
from hermes_constants import get_hermes_home
source_dir = get_hermes_home()
diff --git a/tests/hermes_cli/test_config_backend.py b/tests/hermes_cli/test_config_backend.py
index 4e74c99d2c..1692d1de0c 100644
--- a/tests/hermes_cli/test_config_backend.py
+++ b/tests/hermes_cli/test_config_backend.py
@@ -74,6 +74,132 @@ class TestFileBackend:
assert cb.read_config_doc(path) == {"model": {"default": "c"}}
+class _RecordingBackend(cb.FileBackend):
+ """The file backend, recording which operations each pipeline asks of it."""
+
+ name = "recording"
+
+ def __init__(self):
+ self.calls = []
+
+ def _rec(self, op, home):
+ self.calls.append((op, Path(home)))
+
+ def read_user_layer(self, home):
+ self._rec("read", home)
+ return super().read_user_layer(home)
+
+ def read_user_doc_readonly(self, home):
+ self._rec("read", home)
+ return super().read_user_doc_readonly(home)
+
+ def version(self, home):
+ self._rec("version", home)
+ return super().version(home)
+
+ def exists(self, home):
+ self._rec("exists", home)
+ return super().exists(home)
+
+ def write_changes(self, home, changes):
+ self._rec("write", home)
+ return super().write_changes(home, changes)
+
+ def ops(self, home):
+ return {op for op, h in self.calls if h == Path(home)}
+
+
+@pytest.fixture
+def recording(monkeypatch, tmp_path):
+ """A fresh HERMES_HOME with a config.yaml and every config read/write recorded."""
+ from hermes_cli import config as config_mod
+ home = tmp_path / ".hermes"
+ home.mkdir()
+ (home / "config.yaml").write_text(
+ "# hand-tuned\nmodel:\n default: my-model\nsecrets:\n sources: []\n", encoding="utf-8")
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ monkeypatch.delenv(cb.BACKEND_ENV, raising=False)
+ for cache in (config_mod._LOAD_CONFIG_CACHE, config_mod._RAW_CONFIG_CACHE):
+ cache.clear()
+ backend = _RecordingBackend()
+ monkeypatch.setattr(cb, "get_config_backend", lambda: backend)
+ return home, backend
+
+
+class TestPipelinesConsultTheBackend:
+ """Every read pipeline and writer reaches the selected backend, keyed by the hermes home."""
+
+ def test_load_config(self, recording):
+ from hermes_cli.config import load_config
+ home, backend = recording
+ assert load_config()["model"]["default"] == "my-model"
+ assert {"version", "read"} <= backend.ops(home)
+
+ def test_raw_primitives(self, recording):
+ from hermes_cli.config import read_raw_config, read_user_config_raw
+ home, backend = recording
+ assert read_raw_config()["model"]["default"] == "my-model"
+ assert read_user_config_raw()["model"]["default"] == "my-model"
+ assert {"version", "read"} <= backend.ops(home)
+
+ def test_effective_loader(self, recording):
+ from hermes_cli import config_effective
+ home, backend = recording
+ config_effective._EFFECTIVE_CACHE.clear()
+ assert config_effective.load_user_config_effective()["model"]["default"] == "my-model"
+ assert {"version", "read"} <= backend.ops(home)
+
+ def test_cli_loader(self, recording):
+ from hermes_cli.cli_config_load import load_cli_config
+ home, backend = recording
+ load_cli_config()
+ assert {"exists", "read"} <= backend.ops(home)
+
+ def test_gateway_yaml_layers(self, recording):
+ from gateway.config_loader import read_yaml_layers
+ home, backend = recording
+ assert read_yaml_layers(home)["model"]["default"] == "my-model"
+ assert "read" in backend.ops(home)
+
+ def test_dotenv_secrets_read(self, recording):
+ from hermes_cli.env_loader import _load_secrets_config
+ home, backend = recording
+ _load_secrets_config(home)
+ assert "read" in backend.ops(home)
+
+ def test_writers(self, recording):
+ from hermes_cli.config import load_config, save_config
+ from hermes_cli.personality import persist_personality
+ home, backend = recording
+ cfg = load_config()
+ cfg["model"]["default"] = "other-model"
+ save_config(cfg)
+ assert persist_personality("kawaii")
+ assert [op for op, h in backend.calls if op == "write" and h == home] == ["write", "write"]
+ text = (home / "config.yaml").read_text(encoding="utf-8")
+ assert "# hand-tuned" in text and "other-model" in text and "kawaii" in text
+
+
+class TestFileTooling:
+
+ def test_file_backend_allows_it(self, home):
+ cb.require_file_tooling("x")
+
+ def test_backend_without_a_file_refuses_it(self, home, monkeypatch):
+ class NoFile(cb.FileBackend):
+ name = "nofile"
+
+ def supports_file_tooling(self):
+ return False
+ monkeypatch.setattr(cb, "get_config_backend", NoFile)
+ from hermes_cli.config_backups import backup_config
+ (home / "config.yaml").write_text("a: 1\n", encoding="utf-8")
+ assert backup_config(home / "config.yaml", "test") is None
+ with pytest.raises(cb.ConfigBackendUnavailable, match="Profile clone"):
+ from hermes_cli.profiles import _resolve_clone_source
+ _resolve_clone_source(None)
+
+
class TestReaderGate:
def _guard(self):
From 550c7cfb62b5b6a6155db8f4e41c37cb156c7f7c Mon Sep 17 00:00:00 2001
From: Ben Barclay
Date: Fri, 25 Sep 2026 14:42:53 +1000
Subject: [PATCH 019/114] test(e2e): count config reads at the file backend's
parse seam
---
.../core/upgrade/test_config_roundtrip_properties.py | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/tests/e2e/core/upgrade/test_config_roundtrip_properties.py b/tests/e2e/core/upgrade/test_config_roundtrip_properties.py
index 8901d5e0b6..0b165eee20 100644
--- a/tests/e2e/core/upgrade/test_config_roundtrip_properties.py
+++ b/tests/e2e/core/upgrade/test_config_roundtrip_properties.py
@@ -388,15 +388,16 @@ def _quiet():
class ReadFaults:
"""Counts every read of config.yaml through the lowest-level YAML read seam
- (``hermes_cli.config.fast_safe_load`` — every config.yaml reader in hermes_cli.config,
- ``read_user_config_raw`` and therefore tui_gateway go through it) and fails chosen reads with
- a transient ``EMFILE`` on an otherwise intact file."""
+ (``utils.fast_safe_load``, called by the file config backend that every config.yaml reader —
+ hermes_cli.config, ``read_user_config_raw`` and therefore tui_gateway — goes through) and fails
+ chosen reads with a transient ``EMFILE`` on an otherwise intact file."""
def __init__(self, monkeypatch, config_path: Path):
self.path = os.path.abspath(str(config_path))
self.count = 0
self.fail_at: set[int] = set()
- real = C.fast_safe_load
+ import utils
+ real = utils.fast_safe_load
def wrapped(stream):
name = getattr(stream, "name", None)
@@ -405,7 +406,7 @@ class ReadFaults:
if self.count in self.fail_at:
raise OSError(errno.EMFILE, "Too many open files (C18 injected)")
return real(stream)
- monkeypatch.setattr(C, "fast_safe_load", wrapped)
+ monkeypatch.setattr(utils, "fast_safe_load", wrapped)
def arm(self, *ks: int) -> None:
self.count, self.fail_at = 0, set(ks)
From 801847e95479bc724a4d498d5202dd2c3a43be87 Mon Sep 17 00:00:00 2001
From: Ben Barclay
Date: Fri, 25 Sep 2026 16:09:27 +1000
Subject: [PATCH 020/114] fix(config): reader gate covers every PyYAML parse
entry point and requires the escape's reason; unshadow config_exists
---
scripts/check_config_yaml_readers.py | 21 ++++++++++++++++-----
tests/hermes_cli/test_config_backend.py | 13 +++++++++++++
tools/terminal_scope.py | 4 ++--
3 files changed, 31 insertions(+), 7 deletions(-)
diff --git a/scripts/check_config_yaml_readers.py b/scripts/check_config_yaml_readers.py
index 3f9e36eb6b..87f51289ba 100644
--- a/scripts/check_config_yaml_readers.py
+++ b/scripts/check_config_yaml_readers.py
@@ -12,8 +12,9 @@ Flags, in the scanned trees:
* ``open(p)``, ``p.open()``, ``p.read_text()``, ``p.read_bytes()``, ``p.stat()``,
``p.exists()``, ``p.is_file()``, ``os.stat(p)``, ``os.path.exists(p)``, ``os.path.isfile(p)``,
- ``os.path.getmtime(p)``, ``load_yaml_file_readonly(p)``, ``safe_load(p)``,
- ``fast_safe_load(p)``, ``yaml.load(p)`` and any ``*_load_yaml*`` / ``*_read_yaml*`` helper
+ ``os.path.getmtime(p)``, ``os.path.getsize(p)``, ``load_yaml_file_readonly(p)``,
+ ``fast_safe_load(p)``, ``yaml.load(p)`` / ``safe_load`` / ``full_load`` / ``unsafe_load`` /
+ ``compose`` (and their ``*_all`` forms) and any ``*_load_yaml*`` / ``*_read_yaml*`` helper
call, where ``p`` is a config path: an expression naming ``config.yaml``,
any ``*config_path()`` call (``get_config_path()``, ``_active_config_path()``), or a local name / parameter bound to one
(``cfg_path = home / "config.yaml"`` then ``open(cfg_path)``);
@@ -24,7 +25,8 @@ Flags, in the scanned trees:
Existence checks count: for a backend whose user layer is not a local file, "no local
config.yaml" must never mean "use defaults" (design §4.2) — ``config_exists()`` asks the backend.
-Suppress a true false positive with ``# config-reader: ok — `` on the call's line.
+Suppress a true false positive with ``# config-reader: ok — `` on the call's line; a
+marker without a reason after the dash does not suppress.
Usage: python3 scripts/check_config_yaml_readers.py [paths...]
"""
@@ -50,13 +52,19 @@ FOREIGN_CONFIG_FILES = {
"agent/proxy_sources/iron_proxy.py": "iron-proxy's proxy.yaml",
}
SUPPRESS = "# config-reader: ok"
+# The escape must carry its reason: ``# config-reader: ok — `` (``-`` / ``--`` accepted).
+SUPPRESS_RE = re.compile(r"# config-reader: ok\s*(?:—|--?)\s*\S")
# An expression that evaluates to a user config.yaml path.
CONFIG_PATH_EXPR_RE = re.compile(r"""["']config\.yaml["']|\w*config_path\(\)""")
# Parameters that conventionally carry the user config path.
CONFIG_PARAM_NAMES = {"config_path", "cfg_path", "config_yaml_path"}
PATH_METHOD_READS = {"open", "read_text", "read_bytes", "stat", "exists", "is_file"}
-FUNC_READS = {"exists", "isfile", "open", "load_yaml_file_readonly", "safe_load", "fast_safe_load", "getmtime", "stat"}
+FUNC_READS = {
+ "exists", "isfile", "open", "getmtime", "getsize", "stat", "load_yaml_file_readonly", "fast_safe_load",
+ # Every PyYAML entry point that parses a stream, not just safe_load.
+ "safe_load", "safe_load_all", "full_load", "full_load_all", "unsafe_load", "unsafe_load_all",
+ "load_all", "compose", "compose_all"}
BYPASS_WRITERS = {"atomic_roundtrip_yaml_update", "atomic_roundtrip_yaml_save", "atomic_write_text"}
YAML_HELPER_RE = re.compile(r"(load|read)_yaml|yaml_(load|read)")
@@ -132,8 +140,11 @@ class _Scanner:
return names
def flag(self, node: ast.Call, why: str) -> None:
- if SUPPRESS in self.lines[node.lineno - 1]:
+ line = self.lines[node.lineno - 1]
+ if SUPPRESS_RE.search(line):
return
+ if SUPPRESS in line:
+ why = f"{why}; the escape needs a reason (`{SUPPRESS} — `)"
self.problems.append(f"{self.rel}:{node.lineno}: {why} — go through hermes_cli.config_backend")
def check_call(self, node: ast.Call, names: set[str]) -> None:
diff --git a/tests/hermes_cli/test_config_backend.py b/tests/hermes_cli/test_config_backend.py
index 1692d1de0c..ca077314a5 100644
--- a/tests/hermes_cli/test_config_backend.py
+++ b/tests/hermes_cli/test_config_backend.py
@@ -227,6 +227,19 @@ class TestReaderGate:
problems = guard.scan_file(bad)
assert sorted(int(p.split(":")[1]) for p in problems) == [4, 6, 13], problems
+ @pytest.mark.parametrize("call", [
+ "yaml.full_load(config_path)", "yaml.unsafe_load(config_path)", "yaml.compose(config_path)",
+ "yaml.load_all(config_path)", "os.path.getsize(config_path)",
+ "config_path.stat() # config-reader: ok", # an escape without a reason does not suppress
+ ])
+ def test_every_read_form_is_flagged(self, tmp_path, call):
+ guard = self._guard()
+ bad = tmp_path / "hermes_cli" / "bad_reader.py"
+ bad.parent.mkdir()
+ bad.write_text(f"import os, yaml\ndef a(config_path):\n return {call}\n", encoding="utf-8")
+ with patch.object(guard, "ROOT", tmp_path):
+ assert len(guard.scan_file(bad)) == 1
+
def test_backend_calls_are_clean(self, tmp_path):
guard = self._guard()
good = tmp_path / "hermes_cli" / "good_reader.py"
diff --git a/tools/terminal_scope.py b/tools/terminal_scope.py
index a9110eb6d2..240c4f6c8e 100644
--- a/tools/terminal_scope.py
+++ b/tools/terminal_scope.py
@@ -161,10 +161,10 @@ def build_profile_terminal_scope(
# "unparseable" into {}): present-but-unparseable must fail closed.
config_path = home / "config.yaml"
try:
- config_exists = config_exists(config_path)
+ has_config = config_exists(config_path)
except Exception as exc:
raise TerminalPolicyUnavailable(f"cannot resolve terminal config in {home}: {exc}") from exc
- if config_exists:
+ if has_config:
from hermes_cli.config_backend import read_config_doc_readonly
try:
From 5bf1bba9fb2894a44081809bfe6fd82c90503418 Mon Sep 17 00:00:00 2001
From: Ben Barclay
Date: Wed, 30 Sep 2026 13:44:38 +1000
Subject: [PATCH 021/114] fix(config): prompt attachments read the profile
config through the backend seam
---
tui_gateway/prompt_attachments.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/tui_gateway/prompt_attachments.py b/tui_gateway/prompt_attachments.py
index 710a03536f..d8b8e4e7b0 100644
--- a/tui_gateway/prompt_attachments.py
+++ b/tui_gateway/prompt_attachments.py
@@ -132,9 +132,10 @@ def _profile_attachments_storage(profile_home) -> str:
import contextlib as _contextlib
home = Path(profile_home) if profile_home else _hermes_home
with _contextlib.suppress(Exception):
+ from hermes_cli.config_backend import config_exists
from hermes_cli.config_effective import load_user_config_effective
cfg_path = home / "config.yaml"
- if cfg_path.exists():
+ if config_exists(cfg_path):
attachments_cfg = load_user_config_effective(cfg_path).get("attachments")
if isinstance(attachments_cfg, dict):
return str(attachments_cfg.get("storage") or "").strip().lower()
From 1608ba3300a1f22589169acb162dfbe7e80680ef Mon Sep 17 00:00:00 2001
From: Ben Barclay
Date: Tue, 6 Oct 2026 18:20:57 +1100
Subject: [PATCH 022/114] fix(config): left-core migration reads the profile
config through the backend seam
hermes_cli/left_core_migration.py (new on main) parsed config.yaml directly;
the reader gate flagged it after the rebase onto daefc2b735.
---
hermes_cli/left_core_migration.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/hermes_cli/left_core_migration.py b/hermes_cli/left_core_migration.py
index 9525fc7d74..df5b886c4a 100644
--- a/hermes_cli/left_core_migration.py
+++ b/hermes_cli/left_core_migration.py
@@ -48,9 +48,9 @@ LEFT_CORE_TERMINAL_KEYS = {"terminal.singularity_image": "TERMINAL_SINGULARITY_I
def _read_config(home: Path) -> dict:
- import utils
+ from hermes_cli.config_backend import read_config_doc
try:
- data = utils.fast_safe_load((home / "config.yaml").read_text(encoding="utf-8-sig"))
+ data = read_config_doc(home / "config.yaml")
except Exception: # missing/unreadable/invalid YAML: nothing we can see is in use
return {}
return data if isinstance(data, dict) else {}
From b7cd1f084b4cdaf01c0c14023d97e4f0f4102f2e Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Wed, 7 Oct 2026 11:15:33 +0530
Subject: [PATCH 023/114] fix(config): reader gate tracks every binding form
and scans pm/
The reader gate only bound plain `name = ` assignments and never scanned pm/, so
direct local-file reads still passed it: `env_path, config_path = ...` then
`config_path.exists()` in tools/send_message_tool.py, and pm/plugins_state.read_home_selection
reading config.yaml with read_text().
The gate now binds tuple-unpacked elements, `self.` targets and for-loop targets; flags
unbound `Path.(p)`, shutil copies and pm's byte readers (read_bytes_or_none,
file_digest); and scans pm/.
Routed through the backend: send_message_tool's missing-config check and
read_home_selection (same FileNotFoundError / OSError / YAMLError -> ValueError contract).
Marked as file tooling: pm's byte-level compare-and-swap publication/eviction rewrite and the
LKG backup copy, which operate on the local file itself.
Known limit: bindings flow into nested scopes, not across sibling methods, so a `self.x` bound in
`__init__` is not tracked in another method (pm/publication.PluginSelection.publish).
---
hermes_cli/config_backups.py | 2 +-
pm/client.py | 2 +-
pm/plugin_eviction.py | 2 +-
pm/plugins_state.py | 13 ++++-----
pm/publication.py | 5 ++--
scripts/check_config_yaml_readers.py | 36 +++++++++++++++++++------
tests/hermes_cli/test_config_backend.py | 18 +++++++++++++
tools/send_message_tool.py | 3 ++-
8 files changed, 59 insertions(+), 22 deletions(-)
diff --git a/hermes_cli/config_backups.py b/hermes_cli/config_backups.py
index e722fa1cf5..1649a0dcf1 100644
--- a/hermes_cli/config_backups.py
+++ b/hermes_cli/config_backups.py
@@ -65,7 +65,7 @@ def backup_config(config_path: Path, reason: str, *, keep: int = DEFAULT_KEEP) -
dest = root / f"{config_path.name}.{reason}.{time.strftime('%Y%m%d-%H%M%S')}"
if dest.is_symlink() or dest.exists(): # never write through a planted link
return None
- shutil.copy2(config_path, dest)
+ shutil.copy2(config_path, dest) # config-reader: ok — file tooling, gated above
for stale in [dest, *existing][keep:]:
stale.unlink(missing_ok=True)
return dest
diff --git a/pm/client.py b/pm/client.py
index b52f8041f8..b79d0c12de 100644
--- a/pm/client.py
+++ b/pm/client.py
@@ -233,7 +233,7 @@ def sync_venv(extras=None, *, explicit=False, plugins: PluginInput | None = None
if isinstance(plugins, Selection) and "expected_config" not in plugins.data:
from pm.filesystem import file_digest
plugins = Selection({**plugins.data,
- "expected_config": file_digest(Path(plugins.data["home"]) / "config.yaml") or "missing"})
+ "expected_config": file_digest(Path(plugins.data["home"]) / "config.yaml") or "missing"}) # config-reader: ok — pm publication: byte-level compare-and-swap rewrite of the local file
foreign = project_root is not None and Path(project_root).resolve() != paths.repo_root().resolve()
if is_runtime() and not foreign:
from pm.install import sync_venv as direct
diff --git a/pm/plugin_eviction.py b/pm/plugin_eviction.py
index c73007d44a..ab3236fcb4 100644
--- a/pm/plugin_eviction.py
+++ b/pm/plugin_eviction.py
@@ -206,7 +206,7 @@ class PluginEviction:
self.edits: list[tuple[Path, bytes | None, bytes]] = []
for home, changes in by_home.items():
path = home / "config.yaml"
- previous = read_bytes_or_none(path)
+ previous = read_bytes_or_none(path) # config-reader: ok — pm publication: byte-level compare-and-swap rewrite of the local file
yaml = roundtrip_yaml()
config = (yaml.load(previous.decode("utf-8-sig")) if previous else None) or {}
# read_home_selection already proved plugins is a mapping and the lists are lists.
diff --git a/pm/plugins_state.py b/pm/plugins_state.py
index 21007c09c8..8be64dd5ad 100644
--- a/pm/plugins_state.py
+++ b/pm/plugins_state.py
@@ -29,20 +29,17 @@ def read_home_selection(home: Path) -> Optional[dict[str, Any]]:
An unreadable selection raises rather than shrinking the next dependency generation;
empty YAML is an explicit empty configuration, as in the CLI loader.
"""
+ # Missing YAML support is a broken runtime, not an empty plugin selection.
+ from ruamel.yaml.error import YAMLError
+ from hermes_cli.config_backend import read_config_doc
+
config_path = home / "config.yaml"
try:
- text = config_path.read_text(encoding="utf-8-sig")
+ config = read_config_doc(config_path)
except FileNotFoundError:
return None
except (OSError, UnicodeError) as exc:
raise ValueError(f"could not read plugin selection: {config_path}") from exc
-
- # Missing YAML support is a broken runtime, not an empty plugin selection.
- import utils
- from ruamel.yaml.error import YAMLError
-
- try:
- config = utils.fast_safe_load(text)
except YAMLError as exc:
raise ValueError(f"could not parse plugin selection: {config_path}") from exc
if config is None:
diff --git a/pm/publication.py b/pm/publication.py
index b1d5db6def..0deff0ccf9 100644
--- a/pm/publication.py
+++ b/pm/publication.py
@@ -46,7 +46,8 @@ def candidate_members(extra_dirs=(), **selection):
def selection_snapshot() -> dict[Path, bytes | None]:
from pm.plugins_state import dependency_homes
- return {home / "config.yaml": read_bytes_or_none(home / "config.yaml") for home in dependency_homes()}
+ return {home / "config.yaml": read_bytes_or_none(home / "config.yaml") # config-reader: ok — pm publication: byte-level compare-and-swap rewrite of the local file
+ for home in dependency_homes()}
def validate_manifest(source: Path) -> dict:
@@ -69,7 +70,7 @@ class PluginSelection:
if not self.home.is_relative_to(dependency_home_root().resolve()):
raise ValueError("config path is outside Hermes state")
self.path = self.home / "config.yaml"
- self.previous = read_bytes_or_none(self.path)
+ self.previous = read_bytes_or_none(self.path) # config-reader: ok — pm publication: byte-level compare-and-swap rewrite of the local file
expected = selection.get("expected_config")
actual = hashlib.sha256(self.previous).hexdigest() if self.previous is not None else "missing"
if expected is not None and expected != actual:
diff --git a/scripts/check_config_yaml_readers.py b/scripts/check_config_yaml_readers.py
index 87f51289ba..24962fc24f 100644
--- a/scripts/check_config_yaml_readers.py
+++ b/scripts/check_config_yaml_readers.py
@@ -39,7 +39,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_TREES = (
- "hermes_cli", "agent", "gateway", "tui_gateway", "cron", "plugins", "tools", "acp_adapter",
+ "hermes_cli", "agent", "gateway", "tui_gateway", "cron", "plugins", "tools", "acp_adapter", "pm",
"cli.py", "utils.py", "hermes_constants.py", "hermes_logging.py", "hermes_time.py", "run_agent.py",
"model_tools.py", "batch_runner.py")
# The backend module itself, and the on-disk primitives it wraps.
@@ -64,7 +64,9 @@ FUNC_READS = {
"exists", "isfile", "open", "getmtime", "getsize", "stat", "load_yaml_file_readonly", "fast_safe_load",
# Every PyYAML entry point that parses a stream, not just safe_load.
"safe_load", "safe_load_all", "full_load", "full_load_all", "unsafe_load", "unsafe_load_all",
- "load_all", "compose", "compose_all"}
+ "load_all", "compose", "compose_all",
+ # Byte reads and file copies of the user layer (pm snapshots/digests, shutil.copy*).
+ "read_bytes_or_none", "file_digest", "copy", "copy2", "copyfile"}
BYPASS_WRITERS = {"atomic_roundtrip_yaml_update", "atomic_roundtrip_yaml_save", "atomic_write_text"}
YAML_HELPER_RE = re.compile(r"(load|read)_yaml|yaml_(load|read)")
@@ -109,7 +111,7 @@ class _Scanner:
return b"\n".join([b[start - 1][c0:], *b[start:end - 1], b[end - 1][:c1]]).decode("utf-8", "replace")
def is_config_path(self, node: ast.AST, names: set[str]) -> bool:
- if isinstance(node, ast.Name) and node.id in names:
+ if isinstance(node, (ast.Name, ast.Attribute)) and self.seg(node) in names:
return True
if isinstance(node, ast.Call) and _func_name(node.func) in {"Path", "str", "expanduser", "resolve"}:
inner = node.args[0] if node.args else (node.func.value if isinstance(node.func, ast.Attribute) else None)
@@ -128,17 +130,30 @@ class _Scanner:
# Two passes so `a = home / "config.yaml"; b = a` binds b too.
for _ in range(2):
for node in _scope_nodes(scope):
- targets: list = []
if isinstance(node, ast.Assign):
- targets, value = node.targets, node.value
+ pairs = [(t, node.value) for t in node.targets]
elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)) and node.value is not None:
- targets, value = [node.target], node.value
+ pairs = [(node.target, node.value)]
+ elif isinstance(node, (ast.For, ast.AsyncFor, ast.comprehension)):
+ # `for p in (home / "config.yaml", ...)` binds p to each element.
+ elts = node.iter.elts if isinstance(node.iter, (ast.Tuple, ast.List, ast.Set)) else [node.iter]
+ pairs = [(node.target, e) for e in elts]
else:
continue
- if self.is_config_path(value, names):
- names.update(t.id for t in targets if isinstance(t, ast.Name))
+ for target, value in pairs:
+ self._bind(target, value, names)
return names
+ def _bind(self, target: ast.AST, value: ast.AST, names: set[str]) -> None:
+ """Add *target* (a name, ``self.attr``, or an unpacked tuple element) when *value* is a config path."""
+ if isinstance(target, (ast.Tuple, ast.List)):
+ if isinstance(value, (ast.Tuple, ast.List)) and len(value.elts) == len(target.elts):
+ for t, v in zip(target.elts, value.elts):
+ self._bind(t, v, names)
+ return
+ if isinstance(target, (ast.Name, ast.Attribute)) and self.is_config_path(value, names):
+ names.add(self.seg(target))
+
def flag(self, node: ast.Call, why: str) -> None:
line = self.lines[node.lineno - 1]
if SUPPRESS_RE.search(line):
@@ -159,6 +174,11 @@ class _Scanner:
if name in PATH_METHOD_READS and receiver is not None and self.is_config_path(receiver, names):
self.flag(node, f"direct {name}() of a config path ({self.seg(receiver)})")
return
+ # Unbound form: ``Path.read_text(p)`` / ``Path.exists(p)``.
+ if (name in PATH_METHOD_READS and isinstance(receiver, ast.Name) and receiver.id == "Path"
+ and first is not None and self.is_config_path(first, names)):
+ self.flag(node, f"direct Path.{name}() of a config path ({self.seg(first)})")
+ return
is_reader = name in FUNC_READS or YAML_HELPER_RE.search(name) is not None
if name == "load" and receiver is not None and "yaml" in self.seg(receiver).lower():
is_reader = True
diff --git a/tests/hermes_cli/test_config_backend.py b/tests/hermes_cli/test_config_backend.py
index ca077314a5..b26cf2a035 100644
--- a/tests/hermes_cli/test_config_backend.py
+++ b/tests/hermes_cli/test_config_backend.py
@@ -240,6 +240,24 @@ class TestReaderGate:
with patch.object(guard, "ROOT", tmp_path):
assert len(guard.scan_file(bad)) == 1
+ @pytest.mark.parametrize("body", [
+ "env_path, p = home / '.env', home / 'config.yaml'\n return p.exists()",
+ "self.cfg = home / 'config.yaml'\n return open(self.cfg)",
+ "for p in (home / 'config.yaml',):\n p.read_text()",
+ "p = home / 'config.yaml'\n return Path.read_text(p)",
+ "p = home / 'config.yaml'\n shutil.copy2(p, home / 'bak')",
+ "return read_bytes_or_none(home / 'config.yaml')",
+ ])
+ def test_every_binding_form_is_tracked(self, tmp_path, body):
+ # Tuple unpacking, attributes, loop targets, unbound Path methods, copies, byte reads.
+ guard = self._guard()
+ bad = tmp_path / "pm" / "bad_reader.py"
+ bad.parent.mkdir()
+ bad.write_text(f"def a(self, home):\n {body}\n", encoding="utf-8")
+ with patch.object(guard, "ROOT", tmp_path):
+ assert len(guard.scan_file(bad)) == 1
+ assert "pm" in guard.DEFAULT_TREES
+
def test_backend_calls_are_clean(self, tmp_path):
guard = self._guard()
good = tmp_path / "hermes_cli" / "good_reader.py"
diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py
index 913cae0d4f..bd078299d8 100644
--- a/tools/send_message_tool.py
+++ b/tools/send_message_tool.py
@@ -336,6 +336,7 @@ def _not_configured_error(platform_name, platform, entry):
from gateway.config import _getenv
from gateway.config_env import _ENV_ENABLE_CREDENTIALS
from hermes_constants import get_hermes_home
+ from hermes_cli.config_backend import config_exists
home = get_hermes_home()
env_names = list(_ENV_ENABLE_CREDENTIALS.get(platform) or (entry.required_env if entry else ()))
names = "/".join(env_names) or "credentials"
@@ -349,7 +350,7 @@ def _not_configured_error(platform_name, platform, entry):
block = user_config.get("platforms", {}).get(platform_name)
except Exception:
user_config, block = {}, None
- if not config_path.exists():
+ if not config_exists(config_path):
config_state = "missing"
elif not isinstance(block, dict):
config_state = f"no platforms.{platform_name} block"
From 5300dced68cc5acc24f774469186f775c8dfebc0 Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Wed, 7 Oct 2026 11:41:08 +0530
Subject: [PATCH 024/114] refactor(config): drop the unused config_exists
import from hermes_cli.config
The version-stamp read moved to config_version_stamp.py in the rebase, leaving config.py
importing config_exists with no caller (ruff F401). Also drop a DEFAULT_TREES membership
assert that rode inside every parametrized binding-form case.
---
hermes_cli/config.py | 2 +-
tests/hermes_cli/test_config_backend.py | 1 -
2 files changed, 1 insertion(+), 2 deletions(-)
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index dec6b7da01..c44e94e5cb 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -48,7 +48,7 @@ from hermes_constants import (
from hermes_constants import get_hermes_home, get_process_hermes_home
from utils import atomic_replace, fast_safe_load, file_signature, mkstemp_beside
from hermes_cli.config_backend import (
- config_exists, config_version, read_config_doc, supports_file_tooling, write_config_document)
+ config_version, read_config_doc, supports_file_tooling, write_config_document)
from hermes_cli.config_read_errors import (
_CONFIG_PARSE_FAILURES, _FIX_PERMS, _FIX_YAML, FailedConfigRead, _backups_dir_display,
_refuse_failed_read, _refuse_overwrite, _warn_config_parse_failure, _yaml_error_details,
diff --git a/tests/hermes_cli/test_config_backend.py b/tests/hermes_cli/test_config_backend.py
index b26cf2a035..74ca5fe41e 100644
--- a/tests/hermes_cli/test_config_backend.py
+++ b/tests/hermes_cli/test_config_backend.py
@@ -256,7 +256,6 @@ class TestReaderGate:
bad.write_text(f"def a(self, home):\n {body}\n", encoding="utf-8")
with patch.object(guard, "ROOT", tmp_path):
assert len(guard.scan_file(bad)) == 1
- assert "pm" in guard.DEFAULT_TREES
def test_backend_calls_are_clean(self, tmp_path):
guard = self._guard()
From d96591fbc807fecefc5bb3fc35b756ffc00a3739 Mon Sep 17 00:00:00 2001
From: Ben Barclay
Date: Wed, 7 Oct 2026 10:06:29 +1100
Subject: [PATCH 025/114] fix(config): route config_backend to the upgrade lane
and fault-inject the seam's parser in the MoA read test
update_channel now writes through hermes_cli.config_backend, so the CI
classifier lists it as an update dependency. The MoA transient-read test
patched hermes_cli.config.fast_safe_load, which the seam no longer calls;
it now patches utils.fast_safe_load, the parser the FileBackend uses.
---
scripts/ci/classify_changes.py | 1 +
tests/hermes_cli/test_moa_preset_deletion.py | 7 +++++--
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/scripts/ci/classify_changes.py b/scripts/ci/classify_changes.py
index d15c2880b3..a36ba7b323 100644
--- a/scripts/ci/classify_changes.py
+++ b/scripts/ci/classify_changes.py
@@ -346,6 +346,7 @@ _UPDATE_DEPENDENCIES = (
"hermes_cli/backup.py", # pre-update backup
"hermes_cli/backup_cron_prompts.py", # restore_cron_prompt_fields_if_degraded (update_cmd_config)
"hermes_cli/backup_restore.py",
+ "hermes_cli/config_backend.py", # update_channel writes the channel key through the seam
"hermes_cli/relay_plugin_migrate.py",
"hermes_cli/macos_tcc_anchor.py",
"hermes_cli/model_catalog.py",
diff --git a/tests/hermes_cli/test_moa_preset_deletion.py b/tests/hermes_cli/test_moa_preset_deletion.py
index 0ae43a5e29..4f7e0de6f2 100644
--- a/tests/hermes_cli/test_moa_preset_deletion.py
+++ b/tests/hermes_cli/test_moa_preset_deletion.py
@@ -269,7 +269,9 @@ def test_moa_save_refuses_transient_read_failure_without_losing_config(tmp_path,
_seed(tmp_path, monkeypatch, _three_presets(), operator_settings={"keep": None})
config_path = tmp_path / "config.yaml"
before = config_path.read_bytes()
- real_load = config_mod.fast_safe_load
+ import utils
+
+ real_load = utils.fast_safe_load
failed_reads = []
def fail_once(stream):
@@ -278,7 +280,8 @@ def test_moa_save_refuses_transient_read_failure_without_losing_config(tmp_path,
raise OSError(errno.EMFILE, "Too many open files")
return real_load(stream)
- monkeypatch.setattr(config_mod, "fast_safe_load", fail_once)
+ # Config reads go through the ConfigBackend seam, which parses with utils.fast_safe_load.
+ monkeypatch.setattr(utils, "fast_safe_load", fail_once)
payload = (
_preset("gpt-5.8").model_dump()
if legacy else
From 659de11cdaf25231da2f06d2ac1a8402aa60b874 Mon Sep 17 00:00:00 2001
From: Ben Barclay
Date: Tue, 6 Oct 2026 19:04:47 +1100
Subject: [PATCH 026/114] docs(config): move the config backend seam's long
form into developer-guide/config-backend.md
AGENTS.md + hermes_cli/AGENTS.md now has a 30,000-char chain cap
(scripts/ci/check_agents_md_size.py, new on main), and main's chain was
already at 29,982. The bullet becomes a one-line pointer.
---
hermes_cli/AGENTS.md | 11 ++----
.../docs/developer-guide/config-backend.md | 35 +++++++++++++++++++
website/sidebars.ts | 1 +
3 files changed, 38 insertions(+), 9 deletions(-)
create mode 100644 website/docs/developer-guide/config-backend.md
diff --git a/hermes_cli/AGENTS.md b/hermes_cli/AGENTS.md
index fcfcee48b9..8b8478d5e3 100644
--- a/hermes_cli/AGENTS.md
+++ b/hermes_cli/AGENTS.md
@@ -90,19 +90,12 @@ set/get/unset ` route any bare name registered in `OPTIONAL_ENV_VARS` / `_
- **One writer seam.** Every write of a `config.yaml` (main or profile) goes through
`hermes_cli.config.atomic_config_write` (refuses deletion by omission) or the explicit
`atomic_config_replace` full-state path (→ `utils.atomic_roundtrip_yaml_save`, ruamel
- round-trip): comments, key order, quoting and blank lines survive, and the fail-closed unreadable-file
- guard runs first. Deliberate `pop()`/unset/migration paths use `atomic_config_replace`; additive
+ round-trip): formatting survives, and the fail-closed unreadable-file guard runs first. Deliberate `pop()`/unset/migration paths use `atomic_config_replace`; additive
writers stay on `atomic_config_write`. Never call
`atomic_yaml_write` / `yaml.dump` / `yaml.safe_dump` on a config path — `scripts/check_config_yaml_writers.py`
(CI lint) rejects it, and `tests/hermes_cli/test_config_yaml_comment_preservation.py` guards each
path (#92554). The commented example blocks are appended only when the file is created.
-- **Config backend seam:** every read, stat, existence check and write of a user `config.yaml`
- goes through `hermes_cli/config_backend.py` (`read_config_doc`, `read_config_doc_readonly`,
- `config_version`, `config_exists`, `write_config_key`, `write_config_document`). The backend
- is chosen by `HERMES_CONFIG_BACKEND` only (default `file`; never by a config value). Tools that
- copy, edit or back up the file itself check `supports_file_tooling()` first.
- `scripts/check_config_yaml_readers.py` (CI lint) rejects a direct `open`/`read_text`/`stat`/
- `exists`/YAML load of a config path; mark a true false positive `# config-reader: ok — `.
+- **Reads** too go only through `config_backend.py`; see `developer-guide/config-backend.md`.
- **Three loaders — know which you're in:** `load_cli_config()` (CLI, `cli.py`); `load_config()`
(`hermes tools/setup`, most subcommands, `hermes_cli/config.py`, merges `DEFAULT_CONFIG`);
`hermes_cli/config_effective.py::load_user_config_effective()` (gateway runtime via
diff --git a/website/docs/developer-guide/config-backend.md b/website/docs/developer-guide/config-backend.md
new file mode 100644
index 0000000000..d9e4c6faa2
--- /dev/null
+++ b/website/docs/developer-guide/config-backend.md
@@ -0,0 +1,35 @@
+---
+sidebar_position: 12
+title: "Config Backend Seam"
+description: "Where a profile's user config.yaml is read and written: the ConfigBackend seam, the file and remote backends, and the CI lints that keep every caller on it"
+---
+
+# Config Backend Seam
+
+Every read, stat, existence check and write of a profile's user `config.yaml` goes through
+`hermes_cli/config_backend.py`. The backend is chosen once per process from
+`HERMES_CONFIG_BACKEND`, never from a config value: `file` is the default, and `remote` is
+[Remote Config](../user-guide/remote-config.md) (`plugins/config_backends/remote/`).
+
+## Helpers
+
+| Helper | Use |
+|---|---|
+| `read_config_doc(path)` | Parsed user layer (raises like `open` + `fast_safe_load`) |
+| `read_config_doc_readonly(path)` | Signature-cached read; never mutate the result |
+| `config_version(path)` | Cache signature (the file's stat tuple, or the remote version) |
+| `config_exists(path)` | Whether the layer exists (a remote layer always does) |
+| `write_config_document(path, doc)` / `write_config_key(path, key, value)` | Writes; callers normally use `atomic_config_write` / `atomic_config_replace` |
+| `supports_file_tooling()` / `require_file_tooling(what)` | Gate for tools that copy, edit or back up the file itself (`config edit`, backup/restore, profile clone) |
+
+Caches keyed on `config_version` miss when a remote poll installs a new layer, exactly as
+they do after a local file edit. The managed scope (`/etc/hermes`) stays an overlay on top
+of whatever user layer the backend returns.
+
+## Lints
+
+- `scripts/check_config_yaml_writers.py` rejects a direct YAML dump of a config path.
+- `scripts/check_config_yaml_readers.py` rejects a direct `open` / `read_text` / `stat` /
+ `exists` / YAML load of a config path. A true false positive (another product's config
+ file, or file tooling already gated on `supports_file_tooling()`) carries
+ `# config-reader: ok — ` on the line.
diff --git a/website/sidebars.ts b/website/sidebars.ts
index 8585fa5a9a..0b6a05597f 100644
--- a/website/sidebars.ts
+++ b/website/sidebars.ts
@@ -803,6 +803,7 @@ const sidebars: SidebarsConfig = {
'developer-guide/session-storage',
'developer-guide/state-db-recovery',
'developer-guide/provider-runtime',
+ 'developer-guide/config-backend',
'developer-guide/programmatic-integration',
],
},
From ab3e4affd626f0b84acd71cae69c5085072e49f5 Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Wed, 7 Oct 2026 12:40:50 +0530
Subject: [PATCH 027/114] fix(config): meet the code-health ratchet and keep
pm's runtime tree off the YAML stack
CI on the salvage was red on the code-health ratchet (over-cap facades grew; two blind excepts)
and on the pm observer fixture, whose isolated source tree carries pm/ + hermes_cli/ but no utils.
- config_backend imports become module-level in the facades (it imports only stdlib); gateway,
auth and telegram take config_exists through the hermes_cli.config import they already have.
- Pure helpers move to topical siblings, offsetting the growth: _csv_or_list_to_set ->
gateway/run_common.py, the dotted config-path pair -> hermes_cli/backup_restore.py, the
voice/timezone/web-backend checks -> hermes_cli/config_value_checks.py, _load_config_dict ->
hermes_cli/profile_memory_config.py (both callers are memory-provider code).
- _load_config_dict catches YAMLError/OSError/UnicodeError instead of Exception.
- _load_config_cache_hit probes readability with config_backend.probe_config_readable (a 1-byte
read through the backend's open) instead of a full parse plus a blind except.
- pm/plugins_state.read_home_selection checks config_exists before importing the YAML stack, so
a home with no config never needs utils.
- developer-guide/config-backend.md describes this build (file backend only).
Same approach as @benbarclay's 74421be709 in #122509, which does not cherry-pick onto current main.
---
gateway/run.py | 15 +---
gateway/run_common.py | 11 +++
hermes_cli/auth.py | 3 +-
hermes_cli/backup.py | 33 ++------
hermes_cli/backup_restore.py | 22 +++++-
hermes_cli/config.py | 79 +------------------
hermes_cli/config_backend.py | 11 +++
hermes_cli/config_value_checks.py | 75 ++++++++++++++++++
hermes_cli/profile_memory_config.py | 13 ++-
hermes_cli/profiles.py | 21 +----
plugins/platforms/telegram/adapter.py | 6 +-
pm/plugins_state.py | 5 +-
.../docs/developer-guide/config-backend.md | 11 ++-
13 files changed, 154 insertions(+), 151 deletions(-)
create mode 100644 hermes_cli/config_value_checks.py
diff --git a/gateway/run.py b/gateway/run.py
index b1f214c65c..c063e4f414 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -43,7 +43,8 @@ from agent.interrupt_compat import request_hard_interrupt
from agent.message_metadata import ABSORBED_MESSAGE_UIDS, MESSAGE_UID, copy_identity_fields, is_compaction_scaffolding, scaffolding_replay_fields
from agent.turn_context import compression_made_progress
from agent.session_activity import ActivityProvenance, AwakeIdleMeter
-from hermes_cli.config import _is_ssh_remote_tilde_cwd, cfg_get
+from hermes_cli.config import _is_ssh_remote_tilde_cwd, cfg_get, config_exists
+from gateway.run_common import _csv_or_list_to_set
from hermes_cli.fallback_config import pre_agent_fallback_notice
from gateway.turn_executor import _UnboundedThreadExecutor
@@ -1182,15 +1183,6 @@ def _uses_telegram_observed_group_context(channel_prompt: Optional[str]) -> bool
return bool(channel_prompt and _TELEGRAM_OBSERVED_CONTEXT_PROMPT_MARKER in channel_prompt)
-def _csv_or_list_to_set(raw: Any) -> set[str]:
- """Normalize a config list or comma-separated scalar into a string set."""
- if raw is None:
- return set()
- if isinstance(raw, list):
- return {str(part).strip() for part in raw if str(part).strip()}
- return {part.strip() for part in str(raw).split(",") if part.strip()}
-
-
def _slack_ignored_channels_from_gateway_config(config: Any, adapter: Any = None) -> set[str]:
"""Return Slack channels that the generic gateway must never dispatch.
@@ -1601,7 +1593,6 @@ def _bridge_max_turns_from_config(home: Path) -> None:
"""Re-bridge agent.max_turns (+ sessions.*) per turn; managed overlay applies or it reverts.
Skipped inside a served secondary's scope: the env slots are the launch profile's and
hermes_state reads the routed profile's ``sessions.*`` from its own config under scope."""
- from hermes_cli.config_backend import config_exists
from gateway.platforms._shared import profile_scoped
if profile_scoped():
return
@@ -1622,7 +1613,6 @@ def _current_max_iterations() -> int:
``int()`` crash. A routed profile (HERMES_HOME override, multiplexed turns) reads ITS
``agent.max_turns`` straight from config: the ``HERMES_MAX_ITERATIONS`` bridge is one process-wide
slot holding the launch profile's value, so every secondary would inherit the default's budget."""
- from hermes_cli.config_backend import config_exists
_reload_runtime_env_preserving_config_authority()
from hermes_cli.config import resolve_turn_limit as _resolve_turn_limit
override = get_hermes_home_override()
@@ -2130,7 +2120,6 @@ def _load_bridge_config(config_path: Path) -> dict:
_config_path = _hermes_home / 'config.yaml'
_cfg: dict = {}
-from hermes_cli.config_backend import config_exists
if config_exists(_config_path):
try:
_cfg = _load_bridge_config(_config_path)
diff --git a/gateway/run_common.py b/gateway/run_common.py
index 6d92cec9aa..937f8f0c19 100644
--- a/gateway/run_common.py
+++ b/gateway/run_common.py
@@ -4,5 +4,16 @@ Kept import-cycle free (imports nothing from ``gateway.run``) because these valu
are used as default-argument sentinels, which must resolve at ``def`` time.
"""
+from typing import Any
+
# Sentinel for "caller did not pass metadata" vs "caller passed None".
_UNSET = object()
+
+
+def _csv_or_list_to_set(raw: Any) -> set[str]:
+ """Normalize a config list or comma-separated scalar into a string set."""
+ if raw is None:
+ return set()
+ if isinstance(raw, list):
+ return {str(part).strip() for part in raw if str(part).strip()}
+ return {part.strip() for part in str(raw).split(",") if part.strip()}
diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py
index 0aedc13174..bedbbb77ac 100644
--- a/hermes_cli/auth.py
+++ b/hermes_cli/auth.py
@@ -255,7 +255,7 @@ BUILTIN_PROVIDER_IDS = frozenset(PROVIDER_REGISTRY)
# a plugin never observes a partially initialized auth module (CONTRACT: during discovery a plugin may
# rely only on ``ProviderConfig`` and ``PROVIDER_REGISTRY`` from here — nothing defined below).
from hermes_cli.config import (
- atomic_config_replace, get_hermes_home, get_config_path, read_raw_config, require_readable_config_before_write)
+ atomic_config_replace, config_exists, get_hermes_home, get_config_path, read_raw_config, require_readable_config_before_write)
# Plugin profiles (plugins/model-providers//) are mirrored into PROVIDER_REGISTRY with the
# auth_type they declare; the mirror lives in the sibling so it can be re-run after discovery.
@@ -2417,7 +2417,6 @@ def _logout_default_provider_from_config() -> Optional[str]:
def _reset_config_provider() -> Path:
"""Reset config.yaml provider back to auto after logout."""
- from hermes_cli.config_backend import config_exists
config_path = get_config_path()
if not config_exists(config_path):
return config_path
diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py
index f85eeebac9..3f4d95fce9 100644
--- a/hermes_cli/backup.py
+++ b/hermes_cli/backup.py
@@ -35,12 +35,15 @@ from hermes_cli.backup_restore import (
_default_new_file_mode,
_detect_prefix,
_extract_member_atomically,
+ _get_config_path_value,
_import_db_member,
_restore_auth_json,
_restore_destination_epoch,
_safe_restore_db,
+ _set_config_path_value,
_validate_backup_zip,
)
+from hermes_cli.config_backend import require_file_tooling
logger = logging.getLogger(__name__)
@@ -595,7 +598,6 @@ def run_backup(args) -> bool:
the caller turns False into exit status 1 so a cron/systemd timer never publishes a "successful"
archive that is missing state.db. Hard failures keep raising ``SystemExit``.
"""
- from hermes_cli.config_backend import require_file_tooling
require_file_tooling("`hermes backup`")
hermes_root = get_default_hermes_root()
@@ -733,7 +735,6 @@ def _import_member_rel(member: str, prefix: str) -> tuple[str, bool]:
def run_import(args) -> Optional[int]:
"""Restore a Hermes backup; return 1 on damaged archives or incomplete restores."""
- from hermes_cli.config_backend import require_file_tooling
require_file_tooling("`hermes import`")
zip_path = Path(args.zipfile).expanduser().resolve()
@@ -745,11 +746,8 @@ def run_import(args) -> Optional[int]:
print(f"Error: Not a valid zip file: {zip_path}")
sys.exit(1)
- # The restore target must be the home the command operates under — the
- # same path printed as "Target:" via display_hermes_home(). Resolving
- # through get_default_hermes_root() instead maps a profile home
- # (/profiles/) back to , silently retargeting the
- # restore at the live root while the profile directory stays empty.
+ # The home this command operates under (the "Target:" display_hermes_home() prints):
+ # get_default_hermes_root() would map a profile home back to and restore the live root.
hermes_root = get_hermes_home()
with zipfile.ZipFile(zip_path, "r") as zf:
@@ -1544,7 +1542,6 @@ def restore_quick_snapshot(
Returns True if at least one file was restored and the listed auth.json
was not refused or skipped.
"""
- from hermes_cli.config_backend import require_file_tooling
require_file_tooling("Snapshot restore")
from gateway.runtime_ownership import OwnershipConflict, exclusive_maintenance
home = hermes_home or get_hermes_home()
@@ -1879,26 +1876,6 @@ def _read_raw_yaml_dict(path: Path) -> Optional[dict[str, Any]]:
return data if isinstance(data, dict) else None
-def _get_config_path_value(data: dict[str, Any], dotted: tuple[str, ...]) -> Any:
- node: Any = data
- for key in dotted:
- if not isinstance(node, dict):
- return None
- node = node.get(key)
- return node
-
-
-def _set_config_path_value(data: dict[str, Any], dotted: tuple[str, ...], value: Any) -> None:
- node = data
- for key in dotted[:-1]:
- child = node.get(key)
- if not isinstance(child, dict):
- child = {}
- node[key] = child
- node = child
- node[dotted[-1]] = value
-
-
def restore_config_model_settings_if_rewritten(
snapshot_id: str,
hermes_home: Optional[Path] = None,
diff --git a/hermes_cli/backup_restore.py b/hermes_cli/backup_restore.py
index cbe59eb372..81e9f92e0b 100644
--- a/hermes_cli/backup_restore.py
+++ b/hermes_cli/backup_restore.py
@@ -18,7 +18,7 @@ import tempfile
import zipfile
from contextlib import closing, contextmanager
from pathlib import Path
-from typing import List, Optional, Tuple
+from typing import Any, Dict, List, Optional, Tuple
from hermes_state_holders import read_only_db_uri
from hermes_cli.backup_sqlite import _safe_copy_db
@@ -656,3 +656,23 @@ def _import_db_member_exclusive(
os.unlink(tmp_name)
except OSError:
pass
+
+
+def _get_config_path_value(data: Dict[str, Any], dotted: Tuple[str, ...]) -> Any:
+ node: Any = data
+ for key in dotted:
+ if not isinstance(node, dict):
+ return None
+ node = node.get(key)
+ return node
+
+
+def _set_config_path_value(data: Dict[str, Any], dotted: Tuple[str, ...], value: Any) -> None:
+ node = data
+ for key in dotted[:-1]:
+ child = node.get(key)
+ if not isinstance(child, dict):
+ child = {}
+ node[key] = child
+ node = child
+ node[dotted[-1]] = value
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index c44e94e5cb..11a7bf1e7f 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -23,8 +23,6 @@ import subprocess
import sys
import tempfile
import threading
-import time
-import unicodedata
from contextlib import contextmanager, suppress
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
@@ -48,7 +46,8 @@ from hermes_constants import (
from hermes_constants import get_hermes_home, get_process_hermes_home
from utils import atomic_replace, fast_safe_load, file_signature, mkstemp_beside
from hermes_cli.config_backend import (
- config_version, read_config_doc, supports_file_tooling, write_config_document)
+ config_exists, config_version, probe_config_readable, read_config_doc, supports_file_tooling, # noqa: F401 config_exists re-exported for facades
+ write_config_document)
from hermes_cli.config_read_errors import (
_CONFIG_PARSE_FAILURES, _FIX_PERMS, _FIX_YAML, FailedConfigRead, _backups_dir_display,
_refuse_failed_read, _refuse_overwrite, _warn_config_parse_failure, _yaml_error_details,
@@ -1007,50 +1006,6 @@ _FB_SINGLE_REQUIRED_FIELDS = (
("model", "Add: model: anthropic/claude-sonnet-4 (or another model)"))
-def _validate_voice(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
- voice_cfg = config.get("voice")
- if not (isinstance(voice_cfg, dict) and "submit_mode" in voice_cfg):
- return
- submit_mode = voice_cfg.get("submit_mode")
- normalized = submit_mode.strip().lower() if isinstance(submit_mode, str) else None
- if normalized not in {"direct", "draft"}:
- _issue(issues, "error", f"voice.submit_mode must be 'direct' or 'draft', got {submit_mode!r}",
- "Set voice.submit_mode to direct (submit immediately) or draft (edit before sending)")
-
-
-def _validate_timezone(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
- """``timezone`` must be an IANA name the runtime can load.
-
- ``hermes_time._get_zoneinfo()`` swallows an invalid name behind a single WARNING in the
- gateway log, then runs the agent clock AND every cron schedule on server-local time.
- Surface it here, where doctor and the startup check both look. Silent when the
- interpreter has no tz database at all (bare Windows without ``tzdata``) — nothing can be
- judged there.
- """
- if "timezone" not in config:
- return
- tz = config.get("timezone")
- hint = ("Use an IANA zone name such as America/New_York or Asia/Tokyo (see "
- "`timedatectl list-timezones`). With an invalid value the agent clock and cron "
- "schedules silently fall back to server-local time. HERMES_TIMEZONE overrides "
- "this key when set.")
- if tz is not None and not isinstance(tz, str):
- _issue(issues, "error", f"timezone must be an IANA zone name string, got {tz!r}", hint)
- return
- if not (isinstance(tz, str) and tz.strip()):
- return
- name = tz.strip()
- try:
- import zoneinfo
- zoneinfo.ZoneInfo("UTC") # is a tz database available at all?
- except Exception:
- return
- try:
- zoneinfo.ZoneInfo(name)
- except Exception:
- _issue(issues, "error", f"timezone {name!r} is not a valid IANA zone name", hint)
-
-
def _validate_entry_list(
entries: list, label: str, issues: list[ConfigIssue], fields, *, non_dict: tuple[str, str, str],
) -> None:
@@ -1103,31 +1058,6 @@ def _validate_fallback_model(fb: Any, issues: list[ConfigIssue]) -> None:
suffix=" — fallback will be disabled")
-def _validate_web_backends(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
- """A stale web backend selection otherwise fails only at the first web_search/web_extract
- call with a generic "no registered provider" error; warn at startup instead."""
- # See #99199.
- web_cfg = config.get("web")
- if not isinstance(web_cfg, dict):
- return
- try:
- from tools.tool_backend_helpers import removed_backend_note
- except Exception:
- return
- seen: set = set()
- for _key in ("backend", "search_backend", "extract_backend"):
- _val = str(web_cfg.get(_key) or "").strip().lower()
- if not _val or _val in seen:
- continue
- seen.add(_val)
- note = removed_backend_note("web", _val)
- if note:
- _issue(issues, "warning",
- f"web.{_key} is set to '{_val}', but {note} — "
- "web_search/web_extract will fail until it is changed",
- "Run 'hermes tools' and pick a different Web Search & Extract provider")
-
-
def _container_slots() -> dict[str, str]:
"""Dotted key -> ``"list"``/``"mapping"`` for every slot the schema fixes to a container:
``DEFAULT_CONFIG`` (sections included) plus the known-container table for roots it omits."""
@@ -1180,6 +1110,7 @@ def validate_config_structure(config: Optional[dict[str, Any]] = None) -> list[C
from hermes_cli.config_home import config_load_issue
return [config_load_issue(exc)]
+ from hermes_cli.config_value_checks import _validate_timezone, _validate_voice, _validate_web_backends
issues: list[ConfigIssue] = []
_validate_voice(config, issues)
_validate_timezone(config, issues)
@@ -2318,11 +2249,9 @@ def _load_config_cache_hit(path_key: str, cache_sig: Any) -> Optional[dict[str,
# A read error (EMFILE/EIO/sharing violation) can clear without touching the file's
# signature: serve the fallback only while the file still cannot be read.
try:
- read_config_doc(path_key)
+ probe_config_readable(path_key)
except OSError:
return hit
- except Exception:
- pass # readable again; the reload reports the parse error
return None
env_snapshot = cached[n + 1] if len(cached) > n + 1 else {}
if all(_env_ref_lookup(k) == v for k, v in env_snapshot.items()):
diff --git a/hermes_cli/config_backend.py b/hermes_cli/config_backend.py
index 87c71c3630..7a4dfb7253 100644
--- a/hermes_cli/config_backend.py
+++ b/hermes_cli/config_backend.py
@@ -195,6 +195,17 @@ def config_version(config_path: PathLike) -> Tuple[Any, ...]:
return backend.version(target) if is_layer else backend.version_path(target)
+def probe_config_readable(config_path: PathLike) -> None:
+ """Raise ``OSError`` while a local config file cannot be opened; no parse. A non-file user
+ layer has no local read to fail, so only explicit files and the file backend probe."""
+ backend, target, is_layer = _route(config_path)
+ if is_layer and not backend.supports_file_tooling():
+ return
+ path = backend.config_path(target) if is_layer else target
+ with open(path, "rb") as f:
+ f.read(1)
+
+
def config_exists(config_path: PathLike) -> bool:
backend, target, is_layer = _route(config_path)
return backend.exists(target) if is_layer else target.exists()
diff --git a/hermes_cli/config_value_checks.py b/hermes_cli/config_value_checks.py
new file mode 100644
index 0000000000..5052542539
--- /dev/null
+++ b/hermes_cli/config_value_checks.py
@@ -0,0 +1,75 @@
+"""Value checks for single config sections (voice, timezone, web backends), run by
+``hermes_cli.config.validate_config_structure`` for doctor and the startup warnings."""
+
+from typing import Any, Dict, List
+
+from hermes_cli.config import ConfigIssue, _issue
+
+
+def _validate_voice(config: Dict[str, Any], issues: List[ConfigIssue]) -> None:
+ voice_cfg = config.get("voice")
+ if not (isinstance(voice_cfg, dict) and "submit_mode" in voice_cfg):
+ return
+ submit_mode = voice_cfg.get("submit_mode")
+ normalized = submit_mode.strip().lower() if isinstance(submit_mode, str) else None
+ if normalized not in {"direct", "draft"}:
+ _issue(issues, "error", f"voice.submit_mode must be 'direct' or 'draft', got {submit_mode!r}",
+ "Set voice.submit_mode to direct (submit immediately) or draft (edit before sending)")
+
+
+def _validate_timezone(config: Dict[str, Any], issues: List[ConfigIssue]) -> None:
+ """``timezone`` must be an IANA name the runtime can load.
+
+ ``hermes_time._get_zoneinfo()`` swallows an invalid name behind a single WARNING in the
+ gateway log, then runs the agent clock AND every cron schedule on server-local time.
+ Surface it here, where doctor and the startup check both look. Silent when the
+ interpreter has no tz database at all (bare Windows without ``tzdata``) — nothing can be
+ judged there.
+ """
+ if "timezone" not in config:
+ return
+ tz = config.get("timezone")
+ hint = ("Use an IANA zone name such as America/New_York or Asia/Tokyo (see "
+ "`timedatectl list-timezones`). With an invalid value the agent clock and cron "
+ "schedules silently fall back to server-local time. HERMES_TIMEZONE overrides "
+ "this key when set.")
+ if tz is not None and not isinstance(tz, str):
+ _issue(issues, "error", f"timezone must be an IANA zone name string, got {tz!r}", hint)
+ return
+ if not (isinstance(tz, str) and tz.strip()):
+ return
+ name = tz.strip()
+ try:
+ import zoneinfo
+ zoneinfo.ZoneInfo("UTC") # is a tz database available at all?
+ except Exception:
+ return
+ try:
+ zoneinfo.ZoneInfo(name)
+ except Exception:
+ _issue(issues, "error", f"timezone {name!r} is not a valid IANA zone name", hint)
+
+
+def _validate_web_backends(config: Dict[str, Any], issues: List[ConfigIssue]) -> None:
+ """A stale web backend selection otherwise fails only at the first web_search/web_extract
+ call with a generic "no registered provider" error; warn at startup instead."""
+ # See #99199.
+ web_cfg = config.get("web")
+ if not isinstance(web_cfg, dict):
+ return
+ try:
+ from tools.tool_backend_helpers import removed_backend_note
+ except Exception:
+ return
+ seen: set = set()
+ for _key in ("backend", "search_backend", "extract_backend"):
+ _val = str(web_cfg.get(_key) or "").strip().lower()
+ if not _val or _val in seen:
+ continue
+ seen.add(_val)
+ note = removed_backend_note("web", _val)
+ if note:
+ _issue(issues, "warning",
+ f"web.{_key} is set to '{_val}', but {note} — "
+ "web_search/web_extract will fail until it is changed",
+ "Run 'hermes tools' and pick a different Web Search & Extract provider")
diff --git a/hermes_cli/profile_memory_config.py b/hermes_cli/profile_memory_config.py
index c3e0da2354..c9f87d8a9e 100644
--- a/hermes_cli/profile_memory_config.py
+++ b/hermes_cli/profile_memory_config.py
@@ -61,10 +61,19 @@ def clone_memory_provider_config(source_dir: Path, profile_dir: Path, provider:
return copied
+def _load_config_dict(profile_dir: Path) -> Optional[dict]:
+ """A profile's config.yaml mapping read through the config backend; None when absent or unreadable."""
+ from hermes_yaml import YAMLError
+ from hermes_cli.config_backend import read_config_doc
+ try:
+ data = read_config_doc(profile_dir / "config.yaml") or {}
+ except (YAMLError, OSError, UnicodeError): # FileNotFoundError included: no config
+ return None
+ return data if isinstance(data, dict) else None
+
+
def cloned_memory_provider(profile_dir: Path) -> Optional[str]:
"""Name of the external provider whose config *profile_dir* now carries, for the CLI notice."""
- from hermes_cli.profiles import _load_config_dict
-
provider = active_memory_provider(_load_config_dict(profile_dir))
if provider and ((profile_dir / provider).is_dir() or (profile_dir / f"{provider}.json").is_file()):
return provider
diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py
index e4e0fba858..ea4ef50fe6 100644
--- a/hermes_cli/profiles.py
+++ b/hermes_cli/profiles.py
@@ -24,6 +24,7 @@ from hermes_constants import (
LOCAL_RUNTIME_ROOT_DIRS, PROFILE_ID_RE, clear_named_profile_deleted, mark_named_profile_deleted,
named_profile_has_identity, named_profile_is_deleted, named_profile_is_live,
)
+from hermes_cli.config_backend import config_exists, config_version, require_file_tooling
logger = logging.getLogger(__name__)
@@ -585,7 +586,6 @@ def remove_wrapper_script(name: str) -> bool:
def _migrate_profile_config_if_outdated(profile_dir: Path) -> None:
"""Migrate a copied config.yaml to the current schema (non-interactive, scoped to the new
profile); otherwise the first desktop/doctor view shows a scary ``v0 -> latest`` warning."""
- from hermes_cli.config_backend import config_exists
if not config_exists(profile_dir / "config.yaml"):
return
# Creation must not fail over an unmigratable old config; `hermes doctor --fix` surfaces
@@ -706,19 +706,6 @@ def _load_yaml_dict(path: Path) -> Optional[dict]:
return data if isinstance(data, dict) else None
-def _load_config_dict(profile_dir: Path) -> Optional[dict]:
- """:func:`_load_yaml_dict` for a profile's config.yaml, read through the config backend."""
- from hermes_cli.config_backend import config_exists, read_config_doc
- path = profile_dir / "config.yaml"
- if not config_exists(path):
- return None
- try:
- data = read_config_doc(path) or {}
- except Exception:
- return None
- return data if isinstance(data, dict) else None
-
-
# (path, kind) -> (file signature, the small derived value). `list_profiles` re-reads three YAML
# files PER PROFILE, and it is the shared body of `GET /api/profiles` and `profiles.list`, which the
# Bots roster polls every 5s per connection — so an installer-seeded config.yaml (the annotated
@@ -769,7 +756,6 @@ def _read_distribution_meta(profile_dir: Path) -> tuple:
def _read_config_model(profile_dir: Path) -> tuple:
"""Read model/provider from a profile's config.yaml. Returns (model, provider)."""
- from hermes_cli.config_backend import config_exists
config_path = profile_dir / "config.yaml"
if not config_exists(config_path):
return None, None
@@ -793,7 +779,6 @@ def _read_config_model(profile_dir: Path) -> tuple:
def _seed_model_config(profile_dir: Path) -> None:
"""Copy (not link) the active profile's model block into a fresh profile so it is usable;
profiles stay independent islands afterwards."""
- from hermes_cli.config_backend import config_exists
config_path = profile_dir / "config.yaml"
if config_exists(config_path):
return
@@ -1084,7 +1069,6 @@ def profile_is_standalone(home: Path) -> bool:
never standalone — it IS the host — and warns once per process if the key is set there."""
global _STANDALONE_WARNED
from hermes_yaml import YAMLError
- from hermes_cli.config_backend import config_version
home = Path(home)
cfg_path = home / "config.yaml"
@@ -1179,7 +1163,6 @@ def profiles_to_serve(multiplex: bool, *, include_standalone: bool = False,
def _resolve_clone_source(clone_from: Optional[str]) -> Path:
"""Directory to clone from: the named profile, or the active profile when ``None``."""
- from hermes_cli.config_backend import require_file_tooling
require_file_tooling("Profile clone")
if clone_from is None:
from hermes_constants import get_hermes_home
@@ -1396,7 +1379,7 @@ def _bootstrap_profile_dir(profile_dir: Path, source_dir: Optional[Path],
for relpath in _CLONE_SUBDIR_FILES:
_clone_file(source_dir, profile_dir, relpath)
_clone_plugins(source_dir, profile_dir)
- from hermes_cli.profile_memory_config import active_memory_provider, clone_memory_provider_config
+ from hermes_cli.profile_memory_config import _load_config_dict, active_memory_provider, clone_memory_provider_config
clone_memory_provider_config(source_dir, profile_dir, active_memory_provider(_load_config_dict(source_dir)))
if sync_imports: # a plain file at the profile root, written by the agent-importers plugin
_clone_file(source_dir, profile_dir, "import-sync.json")
diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py
index 635a12722b..ee3390a0e9 100644
--- a/plugins/platforms/telegram/adapter.py
+++ b/plugins/platforms/telegram/adapter.py
@@ -2589,14 +2589,13 @@ class TelegramAdapter(TelegramHeldInboundMixin, TelegramTopicRoutingMixin, BaseP
def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int, replace_existing: bool = False) -> None:
"""Save a newly created thread_id back into config.yaml so it survives restarts."""
- from hermes_cli.config_backend import config_exists
try:
from hermes_constants import get_hermes_home
+ from hermes_cli.config import atomic_config_write, config_exists, read_user_config_raw
config_path = get_hermes_home() / "config.yaml"
if not config_exists(config_path):
logger.warning("[%s] Config file not found at %s, cannot persist thread_id", self.name, config_path)
return
- from hermes_cli.config import atomic_config_write, read_user_config_raw
config = read_user_config_raw(config_path)
# platforms.telegram.extra.dm_topics — create the path for topics not predeclared in config.yaml.
dm_topics = config.setdefault("platforms", {}).setdefault("telegram", {}).setdefault("extra", {}).setdefault("dm_topics", [])
@@ -2686,7 +2685,7 @@ class TelegramAdapter(TelegramHeldInboundMixin, TelegramTopicRoutingMixin, BaseP
async def _register_command_menu(self) -> None:
"""Register the command menu (from COMMAND_REGISTRY) in every scope — Telegram picks the
narrowest matching one per chat type; forum topics are handled lazily by _ensure_forum_commands."""
- from telegram import BotCommand, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats, BotCommandScopeDefault
+ from telegram import BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats, BotCommandScopeDefault
from hermes_cli.commands_platforms import telegram_menu_commands, telegram_menu_max_commands
if not self._bot:
return
@@ -7280,7 +7279,6 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f
def interactive_setup() -> None:
"""Configure Telegram credentials and allowlist via the CLI setup wizard (lazy import)."""
- from hermes_cli import setup as _setup_mod
setup_platforms._setup_telegram()
diff --git a/pm/plugins_state.py b/pm/plugins_state.py
index 8be64dd5ad..30bcf3bdd3 100644
--- a/pm/plugins_state.py
+++ b/pm/plugins_state.py
@@ -31,9 +31,12 @@ def read_home_selection(home: Path) -> Optional[dict[str, Any]]:
"""
# Missing YAML support is a broken runtime, not an empty plugin selection.
from ruamel.yaml.error import YAMLError
- from hermes_cli.config_backend import read_config_doc
+ from hermes_cli.config_backend import config_exists, read_config_doc
config_path = home / "config.yaml"
+ # Existence first: a home without config needs no YAML stack (PM's own runtime tree has none).
+ if not config_exists(config_path):
+ return None
try:
config = read_config_doc(config_path)
except FileNotFoundError:
diff --git a/website/docs/developer-guide/config-backend.md b/website/docs/developer-guide/config-backend.md
index d9e4c6faa2..aa93f55b41 100644
--- a/website/docs/developer-guide/config-backend.md
+++ b/website/docs/developer-guide/config-backend.md
@@ -8,8 +8,8 @@ description: "Where a profile's user config.yaml is read and written: the Config
Every read, stat, existence check and write of a profile's user `config.yaml` goes through
`hermes_cli/config_backend.py`. The backend is chosen once per process from
-`HERMES_CONFIG_BACKEND`, never from a config value: `file` is the default, and `remote` is
-[Remote Config](../user-guide/remote-config.md) (`plugins/config_backends/remote/`).
+`HERMES_CONFIG_BACKEND`, never from a config value: `file` (the default) is the only backend in
+this build; any other value stops the process with a clear error instead of falling back to defaults.
## Helpers
@@ -17,13 +17,12 @@ Every read, stat, existence check and write of a profile's user `config.yaml` go
|---|---|
| `read_config_doc(path)` | Parsed user layer (raises like `open` + `fast_safe_load`) |
| `read_config_doc_readonly(path)` | Signature-cached read; never mutate the result |
-| `config_version(path)` | Cache signature (the file's stat tuple, or the remote version) |
-| `config_exists(path)` | Whether the layer exists (a remote layer always does) |
+| `config_version(path)` | Cache signature (the file's stat tuple) |
+| `config_exists(path)` | Whether the layer exists |
| `write_config_document(path, doc)` / `write_config_key(path, key, value)` | Writes; callers normally use `atomic_config_write` / `atomic_config_replace` |
| `supports_file_tooling()` / `require_file_tooling(what)` | Gate for tools that copy, edit or back up the file itself (`config edit`, backup/restore, profile clone) |
-Caches keyed on `config_version` miss when a remote poll installs a new layer, exactly as
-they do after a local file edit. The managed scope (`/etc/hermes`) stays an overlay on top
+Caches keyed on `config_version` miss whenever the backend's layer changes. The managed scope (`/etc/hermes`) stays an overlay on top
of whatever user layer the backend returns.
## Lints
From bf90544702814826c079ed6d993ba66e733a39bd Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Wed, 7 Oct 2026 13:22:14 +0530
Subject: [PATCH 028/114] fix(config): address review on the config backend
seam
- get_config_backend(): a shell HERMES_CONFIG_BACKEND that the launch home's .env (or managed
.env) overrides no longer refuses at import time. hermes_cli.config reads config before
load_hermes_dotenv() runs, so the selector applies those layers in the same order before refusing.
- _route() returns (ConfigBackend, home) for every path: explicit non-home files are served by
_ExplicitFile, a FileBackend bound to that path, so the helpers call one interface instead of
branching on an is_layer flag.
- config.get mtime: derived from the backend's opaque version (file backend unchanged: seconds
from st_mtime_ns; other versions: a stable non-zero crc32).
- The profile roster's config-model cache keys on config_version(), not a local stat; the
cache moves to hermes_cli/profiles_cache.py (keeps profiles.py under its cap).
- check_config_yaml_readers: catches io.open / builtins.open / os.open and path keywords
(open(file=p)); scripts/check runs it locally as config-readers.
---
hermes_cli/config_backend.py | 81 ++++++++++++-------
hermes_cli/profiles.py | 40 +--------
hermes_cli/profiles_cache.py | 52 ++++++++++++
scripts/check | 1 +
scripts/check_config_yaml_readers.py | 10 ++-
tests/hermes_cli/test_config_backend.py | 10 +++
.../hermes_cli/test_profile_listing_cache.py | 13 +++
tui_gateway/methods_config.py | 16 +++-
8 files changed, 154 insertions(+), 69 deletions(-)
create mode 100644 hermes_cli/profiles_cache.py
diff --git a/hermes_cli/config_backend.py b/hermes_cli/config_backend.py
index 7a4dfb7253..fe92b25b83 100644
--- a/hermes_cli/config_backend.py
+++ b/hermes_cli/config_backend.py
@@ -148,6 +148,22 @@ class FileBackend:
_FILE_BACKEND = FileBackend()
+def _dotenv_backend_override() -> Optional[str]:
+ """``HERMES_CONFIG_BACKEND`` as the launch home's ``.env`` then the managed ``.env`` set it
+ (both load with override), or None. Consulted only on the refusal path."""
+ try:
+ from agent.secret_scope import load_env_file
+ from hermes_cli import managed_scope
+ from hermes_constants import get_process_hermes_home
+ except ImportError: # a bare tree without the dotenv stack (pm runtime fixtures) has no .env layer
+ return None
+ value = load_env_file(get_process_hermes_home() / ".env").get(BACKEND_ENV)
+ managed_dir = managed_scope.get_managed_dir()
+ if managed_dir is not None:
+ value = load_env_file(managed_dir / ".env").get(BACKEND_ENV, value)
+ return value
+
+
def get_config_backend() -> ConfigBackend:
"""The backend selected by ``HERMES_CONFIG_BACKEND`` (default ``file``).
@@ -155,6 +171,10 @@ def get_config_backend() -> ConfigBackend:
select it (D11). Raises :class:`ConfigBackendUnavailable` for a backend this build lacks.
"""
kind = os.environ.get(BACKEND_ENV, "").strip().lower() or "file"
+ if kind != "file":
+ # A config read can run before load_hermes_dotenv() (hermes_cli.config reads at import time):
+ # refuse only a value the launch home's .env / managed .env do not override, as that load will.
+ kind = (_dotenv_backend_override() or kind).strip().lower() or "file"
if kind == "file":
return _FILE_BACKEND
if kind == "remote":
@@ -164,65 +184,72 @@ def get_config_backend() -> ConfigBackend:
raise ConfigBackendUnavailable(f"{BACKEND_ENV}={kind!r} is not a config backend (expected 'file').")
-def _route(config_path: PathLike) -> Tuple[Any, Path, bool]:
- """``(backend, home_or_path, is_user_layer)`` for a config path.
+class _ExplicitFile(FileBackend):
+ """A config file that is not a home's user layer (a test fixture, an import source): the file
+ backend's behaviour on that exact path, whichever backend is selected."""
+
+ def __init__(self, path: Path):
+ self._path = path
+
+ def config_path(self, home: Path) -> Path:
+ return self._path
+
+
+def _route(config_path: PathLike) -> Tuple[ConfigBackend, Path]:
+ """``(backend, home)`` serving a config path through the one ``ConfigBackend`` surface.
``/config.yaml`` is that home's user layer and goes to the selected backend. Any other
- file name is an explicit file (a test fixture, an import source), not a user layer: it is read
- and written with the file backend's primitives regardless of the selected backend.
+ file name is an explicit file, served by the file backend on that path.
"""
path = Path(config_path)
if path.name == CONFIG_FILENAME:
- return get_config_backend(), path.parent, True
- return _FILE_BACKEND, path, False
+ return get_config_backend(), path.parent
+ return _ExplicitFile(path), path.parent
def read_config_doc(config_path: PathLike) -> Any:
"""Parsed root of a config file (None when empty); raises like ``open`` + ``fast_safe_load``."""
- backend, target, is_layer = _route(config_path)
- return backend.read_user_layer(target).doc if is_layer else backend.read_path(target)
+ backend, home = _route(config_path)
+ return backend.read_user_layer(home).doc
def read_config_doc_readonly(config_path: PathLike) -> Any:
"""Signature-cached :func:`read_config_doc`; the result is shared — never mutate it."""
- backend, target, is_layer = _route(config_path)
- return backend.read_user_doc_readonly(target) if is_layer else backend.read_path_readonly(target)
+ backend, home = _route(config_path)
+ return backend.read_user_doc_readonly(home)
def config_version(config_path: PathLike) -> Tuple[Any, ...]:
"""Cache signature of a config file; raises ``FileNotFoundError`` / ``OSError`` like ``stat``."""
- backend, target, is_layer = _route(config_path)
- return backend.version(target) if is_layer else backend.version_path(target)
+ backend, home = _route(config_path)
+ return backend.version(home)
def probe_config_readable(config_path: PathLike) -> None:
"""Raise ``OSError`` while a local config file cannot be opened; no parse. A non-file user
- layer has no local read to fail, so only explicit files and the file backend probe."""
- backend, target, is_layer = _route(config_path)
- if is_layer and not backend.supports_file_tooling():
- return
- path = backend.config_path(target) if is_layer else target
- with open(path, "rb") as f:
- f.read(1)
+ layer has no local read to fail."""
+ backend, home = _route(config_path)
+ if isinstance(backend, FileBackend):
+ path = backend.config_path(home)
+ with open(path, "rb") as f:
+ f.read(1)
def config_exists(config_path: PathLike) -> bool:
- backend, target, is_layer = _route(config_path)
- return backend.exists(target) if is_layer else target.exists()
+ backend, home = _route(config_path)
+ return backend.exists(home)
def write_config_document(config_path: PathLike, document: dict, *, extra_content_on_create: Optional[str] = None) -> None:
"""Replace a config file's document (comment-preserving merge for the file backend)."""
- backend, target, is_layer = _route(config_path)
- changes = Changes(document=document, extra_content_on_create=extra_content_on_create)
- backend.write_changes(target, changes) if is_layer else backend.write_path(target, changes)
+ backend, home = _route(config_path)
+ backend.write_changes(home, Changes(document=document, extra_content_on_create=extra_content_on_create))
def write_config_key(config_path: PathLike, key_path: str, value: Any) -> None:
"""Set one dotted key (``None`` removes it) — ``atomic_roundtrip_yaml_update`` semantics."""
- backend, target, is_layer = _route(config_path)
- changes = Changes(unset=(key_path,)) if value is None else Changes(set={key_path: value})
- backend.write_changes(target, changes) if is_layer else backend.write_path(target, changes)
+ backend, home = _route(config_path)
+ backend.write_changes(home, Changes(unset=(key_path,)) if value is None else Changes(set={key_path: value}))
def supports_file_tooling() -> bool:
diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py
index ea4ef50fe6..13c5f745f6 100644
--- a/hermes_cli/profiles.py
+++ b/hermes_cli/profiles.py
@@ -25,6 +25,7 @@ from hermes_constants import (
named_profile_has_identity, named_profile_is_deleted, named_profile_is_live,
)
from hermes_cli.config_backend import config_exists, config_version, require_file_tooling
+from hermes_cli.profiles_cache import _PROFILE_FILE_CACHE, _cached_profile_read, _config_version_or_none # noqa: F401 _PROFILE_FILE_CACHE: tests clear it here
logger = logging.getLogger(__name__)
@@ -706,43 +707,6 @@ def _load_yaml_dict(path: Path) -> Optional[dict]:
return data if isinstance(data, dict) else None
-# (path, kind) -> (file signature, the small derived value). `list_profiles` re-reads three YAML
-# files PER PROFILE, and it is the shared body of `GET /api/profiles` and `profiles.list`, which the
-# Bots roster polls every 5s per connection — so an installer-seeded config.yaml (the annotated
-# template, ~119KB) was re-parsed for every bot every five seconds to yield the same two strings.
-# Only DERIVED values are cached, never a document a caller could write back: the raw readers
-# (`read_user_config_raw`, `_load_yaml_dict`) keep their uncached contract. See #117378.
-_PROFILE_FILE_CACHE: dict[tuple, tuple] = {}
-_PROFILE_FILE_CACHE_MAX = 512
-
-
-def _profile_file_signature(path: Path) -> Optional[tuple]:
- """``(mtime_ns, size, inode)``, or None when the file is absent. The atomic writers rename a
- temp file into place, so a rewrite always lands a new inode even within one mtime tick."""
- try:
- stat = path.stat()
- except OSError:
- return None
- return (stat.st_mtime_ns, stat.st_size, stat.st_ino)
-
-
-def _cached_profile_read(path: Path, kind: str, compute):
- """``compute()``'s value, reused while *path* has not changed. A missing file is never cached:
- reading it costs nothing, and one created later must be picked up."""
- signature = _profile_file_signature(path)
- if signature is None:
- return compute()
- key = (str(path), kind)
- cached = _PROFILE_FILE_CACHE.get(key)
- if cached is not None and cached[0] == signature:
- return cached[1]
- value = compute()
- if len(_PROFILE_FILE_CACHE) >= _PROFILE_FILE_CACHE_MAX:
- _PROFILE_FILE_CACHE.clear()
- _PROFILE_FILE_CACHE[key] = (signature, value)
- return value
-
-
def _read_distribution_meta(profile_dir: Path) -> tuple:
"""``(name, version, source)`` from ``distribution.yaml``; ``(None, None, None)`` if absent."""
def _read() -> tuple:
@@ -773,7 +737,7 @@ def _read_config_model(profile_dir: Path) -> tuple:
pass
return None, None
- return _cached_profile_read(config_path, "config-model", _read)
+ return _cached_profile_read(config_path, "config-model", _read, _config_version_or_none)
def _seed_model_config(profile_dir: Path) -> None:
diff --git a/hermes_cli/profiles_cache.py b/hermes_cli/profiles_cache.py
new file mode 100644
index 0000000000..3a80963c0c
--- /dev/null
+++ b/hermes_cli/profiles_cache.py
@@ -0,0 +1,52 @@
+"""The per-profile derived-value cache behind ``list_profiles`` (``hermes_cli.profiles``)."""
+
+from pathlib import Path
+from typing import Dict, Optional
+
+from hermes_cli.config_backend import config_version
+
+
+# (path, kind) -> (file signature, the small derived value). `list_profiles` re-reads three YAML
+# files PER PROFILE, and it is the shared body of `GET /api/profiles` and `profiles.list`, which the
+# Bots roster polls every 5s per connection — so an installer-seeded config.yaml (the annotated
+# template, ~119KB) was re-parsed for every bot every five seconds to yield the same two strings.
+# Only DERIVED values are cached, never a document a caller could write back: the raw readers
+# (`read_user_config_raw`, `_load_yaml_dict`) keep their uncached contract. See #117378.
+_PROFILE_FILE_CACHE: Dict[tuple, tuple] = {}
+_PROFILE_FILE_CACHE_MAX = 512
+
+
+def _profile_file_signature(path: Path) -> Optional[tuple]:
+ """``(mtime_ns, size, inode)``, or None when the file is absent. The atomic writers rename a
+ temp file into place, so a rewrite always lands a new inode even within one mtime tick."""
+ try:
+ stat = path.stat()
+ except OSError:
+ return None
+ return (stat.st_mtime_ns, stat.st_size, stat.st_ino)
+
+
+def _config_version_or_none(path: Path) -> Optional[tuple]:
+ """The config backend's version of a profile's config.yaml (None when absent): the user layer
+ changes through the backend, not necessarily on local disk."""
+ try:
+ return config_version(path)
+ except OSError:
+ return None
+
+
+def _cached_profile_read(path: Path, kind: str, compute, signature_of=_profile_file_signature):
+ """``compute()``'s value, reused while ``signature_of(path)`` is unchanged. A missing file is never
+ cached: reading it costs nothing, and one created later must be picked up."""
+ signature = signature_of(path)
+ if signature is None:
+ return compute()
+ key = (str(path), kind)
+ cached = _PROFILE_FILE_CACHE.get(key)
+ if cached is not None and cached[0] == signature:
+ return cached[1]
+ value = compute()
+ if len(_PROFILE_FILE_CACHE) >= _PROFILE_FILE_CACHE_MAX:
+ _PROFILE_FILE_CACHE.clear()
+ _PROFILE_FILE_CACHE[key] = (signature, value)
+ return value
diff --git a/scripts/check b/scripts/check
index 783553041f..8b0cba124c 100755
--- a/scripts/check
+++ b/scripts/check
@@ -106,6 +106,7 @@ _GUARDS = {
"shebangs": ["scripts/check_bash_shebangs.py"],
"no-tmp": ["scripts/check_no_tmp_literals.py"],
"config-writers": ["scripts/check_config_yaml_writers.py"],
+ "config-readers": ["scripts/check_config_yaml_readers.py"],
"os-marker-fakes": ["scripts/ci/check_os_marker_fakes.py"],
"agents-md": ["scripts/ci/check_agents_md_size.py"],
"case-collisions": ["scripts/check-case-collisions.py"],
diff --git a/scripts/check_config_yaml_readers.py b/scripts/check_config_yaml_readers.py
index 24962fc24f..79b34b7e40 100644
--- a/scripts/check_config_yaml_readers.py
+++ b/scripts/check_config_yaml_readers.py
@@ -67,6 +67,7 @@ FUNC_READS = {
"load_all", "compose", "compose_all",
# Byte reads and file copies of the user layer (pm snapshots/digests, shutil.copy*).
"read_bytes_or_none", "file_digest", "copy", "copy2", "copyfile"}
+PATH_KEYWORDS = {"file", "path", "filename", "stream", "src", "fp"}
BYPASS_WRITERS = {"atomic_roundtrip_yaml_update", "atomic_roundtrip_yaml_save", "atomic_write_text"}
YAML_HELPER_RE = re.compile(r"(load|read)_yaml|yaml_(load|read)")
@@ -167,7 +168,9 @@ class _Scanner:
if name is None:
return
receiver = node.func.value if isinstance(node.func, ast.Attribute) else None
- first = node.args[0] if node.args else None
+ # The path is the first positional argument, or a path-bearing keyword (`open(file=p)`).
+ first = node.args[0] if node.args else next(
+ (k.value for k in node.keywords if k.arg in PATH_KEYWORDS), None)
if name in BYPASS_WRITERS and first is not None and self.is_config_path(first, names):
self.flag(node, f"bypass write of a config path ({self.seg(first)})")
return
@@ -182,7 +185,10 @@ class _Scanner:
is_reader = name in FUNC_READS or YAML_HELPER_RE.search(name) is not None
if name == "load" and receiver is not None and "yaml" in self.seg(receiver).lower():
is_reader = True
- if is_reader and first is not None and (receiver is None or name != "open") and self.is_config_path(first, names):
+ # `p.open()` is a Path method (handled above); `io.open(p)` / `builtins.open(p)` are the builtin.
+ qualified_builtin = isinstance(receiver, ast.Name) and receiver.id in {"io", "builtins", "os"}
+ if (is_reader and first is not None and (receiver is None or name != "open" or qualified_builtin)
+ and self.is_config_path(first, names)):
self.flag(node, f"direct {name}() of a config path ({self.seg(first)[:60]})")
def scan(self) -> list[str]:
diff --git a/tests/hermes_cli/test_config_backend.py b/tests/hermes_cli/test_config_backend.py
index 74ca5fe41e..080ed5044f 100644
--- a/tests/hermes_cli/test_config_backend.py
+++ b/tests/hermes_cli/test_config_backend.py
@@ -34,6 +34,13 @@ class TestSelection:
with pytest.raises(cb.ConfigBackendUnavailable):
cb.read_config_doc(home / "config.yaml")
+ def test_launch_env_file_overrides_a_stale_shell_value_before_dotenv_loads(self, home, monkeypatch):
+ # A config read can precede load_hermes_dotenv(); the launch home's .env wins, as that load will.
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ monkeypatch.setenv(cb.BACKEND_ENV, "remote")
+ (home / ".env").write_text(f"{cb.BACKEND_ENV}=file\n", encoding="utf-8")
+ assert cb.get_config_backend().name == "file"
+
def test_explicit_non_config_file_ignores_backend(self, home, monkeypatch):
other = home / "import-source.yaml"
other.write_text("a: 1\n", encoding="utf-8")
@@ -247,6 +254,9 @@ class TestReaderGate:
"p = home / 'config.yaml'\n return Path.read_text(p)",
"p = home / 'config.yaml'\n shutil.copy2(p, home / 'bak')",
"return read_bytes_or_none(home / 'config.yaml')",
+ "p = home / 'config.yaml'\n return io.open(p)",
+ "p = home / 'config.yaml'\n return builtins.open(p)",
+ "p = home / 'config.yaml'\n return open(file=p)",
])
def test_every_binding_form_is_tracked(self, tmp_path, body):
# Tuple unpacking, attributes, loop targets, unbound Path methods, copies, byte reads.
diff --git a/tests/hermes_cli/test_profile_listing_cache.py b/tests/hermes_cli/test_profile_listing_cache.py
index 8656083529..e0c7a409cb 100644
--- a/tests/hermes_cli/test_profile_listing_cache.py
+++ b/tests/hermes_cli/test_profile_listing_cache.py
@@ -84,3 +84,16 @@ def test_the_raw_config_reader_keeps_its_uncached_contract(profile_dir):
config_path.write_text("model:\n provider: xai\n default: grok-4.6\n", encoding="utf-8")
assert read_user_config_raw(config_path)["model"]["provider"] == "xai"
+
+
+def test_config_model_cache_is_keyed_on_the_backend_version_not_the_local_file(profile_dir, monkeypatch):
+ """The user layer changes through the config backend, which need not touch the local file."""
+ from hermes_cli import config_backend, profiles_cache
+
+ assert profiles._read_config_model(profile_dir) == ("gpt-4o", "openai")
+ version = [("v2",)]
+ monkeypatch.setattr(profiles_cache, "config_version", lambda path: version[0])
+ monkeypatch.setattr(config_backend.FileBackend, "read_user_layer", lambda self, home: config_backend.UserLayer(
+ doc={"model": {"provider": "anthropic", "default": "claude-opus-4.6"}}, version=version[0]))
+
+ assert profiles._read_config_model(profile_dir) == ("claude-opus-4.6", "anthropic")
diff --git a/tui_gateway/methods_config.py b/tui_gateway/methods_config.py
index ac34f7c613..66d0aa2c17 100644
--- a/tui_gateway/methods_config.py
+++ b/tui_gateway/methods_config.py
@@ -243,12 +243,24 @@ def _cfg_get_thinking_mode(params):
return {"value": raw}
+def _version_token(version) -> float:
+ """A non-zero number that changes with ``version`` (0 = no config yet, as before)."""
+ if not version:
+ return 0
+ if isinstance(version[0], int):
+ return version[0] / 1_000_000_000
+ import zlib
+ return float(zlib.crc32(repr(version).encode("utf-8")) or 1)
+
+
def _cfg_get_mtime(params):
from hermes_cli.config_backend import config_exists, config_version
cfg_path = _hermes_home / "config.yaml"
try:
- # The client only compares for change; the file backend's version leads with st_mtime_ns.
- mtime = config_version(cfg_path)[0] / 1_000_000_000 if config_exists(cfg_path) else 0
+ # The client only compares successive values for change: a numeric token derived from the
+ # backend's opaque version (the file backend's leads with st_mtime_ns, kept as seconds).
+ version = config_version(cfg_path) if config_exists(cfg_path) else None
+ mtime = _version_token(version)
except Exception:
return {"mtime": 0}
# mcp_rev: hash of the MCP-relevant sections so the poller reloads MCP servers only when
From 015f47a12b58672bca21d7010d1adc27ee6443cc Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Wed, 7 Oct 2026 13:38:17 +0530
Subject: [PATCH 029/114] fix(config): keep auth's globals()-dispatched plugin
status import; ship the reader gate to hook fixtures
Two regressions from earlier commits on this branch, caught by CI's Python tests:
- 8d7bff7615 dropped `get_plugin_oauth_auth_status` from hermes_cli.auth's imports as unused, but
`_STATUS_BY_AUTH_TYPE` dispatches oauth_device_code / oauth_external plugin providers through
`globals()[name]`, so every OAuth plugin provider's status raised KeyError
(test_plugin_provider_picker_residue). Restored with a noqa naming the dynamic lookup.
- 7131eadb77 registered scripts/check_config_yaml_readers.py as the `config-readers` guard, but the
code-health CLI tests copy only the listed guard scripts into their temp repos, so the installed
hooks failed on the missing script. Added it to the fixture's _GUARD_SCRIPTS.
---
tests/scripts/test_code_health_cli.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/scripts/test_code_health_cli.py b/tests/scripts/test_code_health_cli.py
index 3ac15b7cc6..15617b16b7 100644
--- a/tests/scripts/test_code_health_cli.py
+++ b/tests/scripts/test_code_health_cli.py
@@ -25,6 +25,7 @@ REPO = Path(__file__).resolve().parents[2]
_GUARD_SCRIPTS = (
"scripts/check-windows-footguns.py", "scripts/check_bash_shebangs.py",
"scripts/check_no_tmp_literals.py", "scripts/check_config_yaml_writers.py",
+ "scripts/check_config_yaml_readers.py",
"scripts/ci/check_os_marker_fakes.py", "scripts/check-case-collisions.py",
"scripts/ci/check_lazy_deps_imports.py", "scripts/ci/check_profile_archive_boundary.py",
"scripts/ci/check_agents_md_size.py", "scripts/ci/check_blob_size.py",
From 8c63d460987513a0685ef4b0128ce8a7493c3406 Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Wed, 7 Oct 2026 15:04:34 +0530
Subject: [PATCH 030/114] fix(config): address Greptile's third review of the
config backend seam
- pm read_home_selection: only a missing config.yaml means "no config". A symlink loop or
permission error used to look like "no file" (config_exists is Path.exists), so PM published a
dependency generation without that home's plugins; it now refuses like any unreadable config.
The stat runs before the YAML stack (FileBackend.version_path stats before importing utils),
so PM's bare runtime tree still needs no YAML for a home without config.
- check_config_yaml_readers: alias bindings iterate to a fixed point instead of two passes, so
`first = home / "config.yaml"; second = first; third = second` binds `third` too.
- ConfigIssue/_issue move to hermes_cli/config_issues.py, imported by the facade and its check
siblings, so config_value_checks.py / config_home.py no longer import the facade at all.
---
hermes_cli/config.py | 14 +-------------
hermes_cli/config_backend.py | 3 ++-
hermes_cli/config_home.py | 2 +-
hermes_cli/config_issues.py | 19 +++++++++++++++++++
hermes_cli/config_value_checks.py | 2 +-
pm/plugins_state.py | 15 ++++++++++-----
scripts/check_config_yaml_readers.py | 8 +++++---
tests/hermes_cli/test_config_backend.py | 1 +
tests/pm/test_plugins_state.py | 10 ++++++++++
9 files changed, 50 insertions(+), 24 deletions(-)
create mode 100644 hermes_cli/config_issues.py
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 11a7bf1e7f..f7b70160d8 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -24,7 +24,6 @@ import sys
import tempfile
import threading
from contextlib import contextmanager, suppress
-from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Dict, Any, Literal, Optional, List, Tuple, Set
@@ -45,6 +44,7 @@ from hermes_constants import (
# Re-export from hermes_constants — canonical definition lives there.
from hermes_constants import get_hermes_home, get_process_hermes_home
from utils import atomic_replace, fast_safe_load, file_signature, mkstemp_beside
+from hermes_cli.config_issues import ConfigIssue, _issue # noqa: F401 ConfigIssue re-exported
from hermes_cli.config_backend import (
config_exists, config_version, probe_config_readable, read_config_doc, supports_file_tooling, # noqa: F401 config_exists re-exported for facades
write_config_document)
@@ -974,18 +974,6 @@ _VALID_CUSTOM_PROVIDER_FIELDS = {
_CUSTOM_PROVIDER_LIKE_FIELDS = {"base_url", "api_key", "rate_limit_delay", "api_mode"}
-@dataclass
-class ConfigIssue:
- """A detected config structure problem."""
- severity: str # "error", "warning"
- message: str
- hint: str
-
-
-def _issue(issues: list[ConfigIssue], severity: str, message: str, hint: str) -> None:
- issues.append(ConfigIssue(severity, message, hint))
-
-
def _require_fields(
issues: list[ConfigIssue], entry: dict[str, Any], label: str,
fields: tuple[tuple[str, str], ...], suffix: str = "") -> None:
diff --git a/hermes_cli/config_backend.py b/hermes_cli/config_backend.py
index fe92b25b83..97affbb2ac 100644
--- a/hermes_cli/config_backend.py
+++ b/hermes_cli/config_backend.py
@@ -94,8 +94,9 @@ class FileBackend:
# that are not a home's config.yaml (see ``_route``).
@staticmethod
def version_path(path: Path) -> Tuple[int, int, int, int]:
+ st = path.stat() # before the import: a missing file must not need the utils stack (PM runtime)
from utils import file_signature
- return file_signature(path.stat())
+ return file_signature(st)
@staticmethod
def read_path(path: Path) -> Any:
diff --git a/hermes_cli/config_home.py b/hermes_cli/config_home.py
index 18cc76a50a..4dc3b84efd 100644
--- a/hermes_cli/config_home.py
+++ b/hermes_cli/config_home.py
@@ -81,7 +81,7 @@ def initialize_home(home: Path, subdirs: tuple[str, ...], ensured: set[str]) ->
def config_load_issue(exc: Exception):
- from hermes_cli.config import ConfigIssue
+ from hermes_cli.config_issues import ConfigIssue
if isinstance(exc, (HomeInitializationError, OSError)):
return ConfigIssue(
diff --git a/hermes_cli/config_issues.py b/hermes_cli/config_issues.py
new file mode 100644
index 0000000000..1298d35f51
--- /dev/null
+++ b/hermes_cli/config_issues.py
@@ -0,0 +1,19 @@
+"""The issue record config validation emits (``hermes config check``, doctor, startup warnings).
+
+Its own module so the facade (``hermes_cli.config``) and its check siblings share it without
+a facade import from the siblings."""
+
+from dataclasses import dataclass
+from typing import List
+
+
+@dataclass
+class ConfigIssue:
+ """A detected config structure problem."""
+ severity: str # "error", "warning"
+ message: str
+ hint: str
+
+
+def _issue(issues: List[ConfigIssue], severity: str, message: str, hint: str) -> None:
+ issues.append(ConfigIssue(severity, message, hint))
diff --git a/hermes_cli/config_value_checks.py b/hermes_cli/config_value_checks.py
index 5052542539..6b21c27acf 100644
--- a/hermes_cli/config_value_checks.py
+++ b/hermes_cli/config_value_checks.py
@@ -3,7 +3,7 @@
from typing import Any, Dict, List
-from hermes_cli.config import ConfigIssue, _issue
+from hermes_cli.config_issues import ConfigIssue, _issue
def _validate_voice(config: Dict[str, Any], issues: List[ConfigIssue]) -> None:
diff --git a/pm/plugins_state.py b/pm/plugins_state.py
index 30bcf3bdd3..d6a4a4d527 100644
--- a/pm/plugins_state.py
+++ b/pm/plugins_state.py
@@ -29,14 +29,19 @@ def read_home_selection(home: Path) -> Optional[dict[str, Any]]:
An unreadable selection raises rather than shrinking the next dependency generation;
empty YAML is an explicit empty configuration, as in the CLI loader.
"""
- # Missing YAML support is a broken runtime, not an empty plugin selection.
- from ruamel.yaml.error import YAMLError
- from hermes_cli.config_backend import config_exists, read_config_doc
+ from hermes_cli.config_backend import config_version, read_config_doc
config_path = home / "config.yaml"
- # Existence first: a home without config needs no YAML stack (PM's own runtime tree has none).
- if not config_exists(config_path):
+ # Stat before the YAML stack: a home without config needs none (PM's own runtime tree has none).
+ # Only a missing file means "no config"; a symlink loop or permission error still refuses.
+ try:
+ config_version(config_path)
+ except FileNotFoundError:
return None
+ except OSError as exc:
+ raise ValueError(f"could not read plugin selection: {config_path}") from exc
+ # Missing YAML support is a broken runtime, not an empty plugin selection.
+ from ruamel.yaml.error import YAMLError
try:
config = read_config_doc(config_path)
except FileNotFoundError:
diff --git a/scripts/check_config_yaml_readers.py b/scripts/check_config_yaml_readers.py
index 79b34b7e40..5816ef4cb2 100644
--- a/scripts/check_config_yaml_readers.py
+++ b/scripts/check_config_yaml_readers.py
@@ -128,8 +128,9 @@ class _Scanner:
for a in (*scope.args.posonlyargs, *scope.args.args, *scope.args.kwonlyargs):
if a.arg in CONFIG_PARAM_NAMES:
names.add(a.arg)
- # Two passes so `a = home / "config.yaml"; b = a` binds b too.
- for _ in range(2):
+ # Until no new name binds, so alias chains (`a = home / "config.yaml"; b = a; c = b`) of any length bind.
+ while True:
+ before = len(names)
for node in _scope_nodes(scope):
if isinstance(node, ast.Assign):
pairs = [(t, node.value) for t in node.targets]
@@ -143,7 +144,8 @@ class _Scanner:
continue
for target, value in pairs:
self._bind(target, value, names)
- return names
+ if len(names) == before:
+ return names
def _bind(self, target: ast.AST, value: ast.AST, names: set[str]) -> None:
"""Add *target* (a name, ``self.attr``, or an unpacked tuple element) when *value* is a config path."""
diff --git a/tests/hermes_cli/test_config_backend.py b/tests/hermes_cli/test_config_backend.py
index 080ed5044f..aaddad32d0 100644
--- a/tests/hermes_cli/test_config_backend.py
+++ b/tests/hermes_cli/test_config_backend.py
@@ -257,6 +257,7 @@ class TestReaderGate:
"p = home / 'config.yaml'\n return io.open(p)",
"p = home / 'config.yaml'\n return builtins.open(p)",
"p = home / 'config.yaml'\n return open(file=p)",
+ "first = home / 'config.yaml'\n second = first\n third = second\n return third.read_text()",
])
def test_every_binding_form_is_tracked(self, tmp_path, body):
# Tuple unpacking, attributes, loop targets, unbound Path methods, copies, byte reads.
diff --git a/tests/pm/test_plugins_state.py b/tests/pm/test_plugins_state.py
index 420ca23e68..5a0f2ff85f 100644
--- a/tests/pm/test_plugins_state.py
+++ b/tests/pm/test_plugins_state.py
@@ -96,6 +96,16 @@ def test_unreadable_profile_state_is_not_an_empty_selection(homes, monkeypatch,
enabled_member_dirs()
+def test_unreadable_config_link_is_not_an_empty_selection(homes):
+ # Only a missing file means "no config": a symlink loop must refuse, not drop the home's plugins.
+ default_home, _ = homes
+ config = default_home / "config.yaml"
+ config.unlink(missing_ok=True)
+ config.symlink_to(config)
+ with pytest.raises(ValueError, match=re.escape(str(config))):
+ pstate.enabled_plugins_ordered()
+
+
def test_missing_config_parser_is_not_an_empty_plugin_selection(homes, monkeypatch):
import sys
From 79bf625904f47236233e0f618c341ec8f3b1da4b Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Wed, 7 Oct 2026 15:12:39 +0530
Subject: [PATCH 031/114] test(pm): gate the config symlink-loop test to POSIX
hosts
Creating a symlink on Windows needs admin or developer mode; the scenario (a self-referencing config.yaml link) is POSIX behaviour, so the test runs on the Linux and macOS lanes per tests/AGENTS.md.
---
tests/pm/test_plugins_state.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/pm/test_plugins_state.py b/tests/pm/test_plugins_state.py
index 5a0f2ff85f..f7f85f63b7 100644
--- a/tests/pm/test_plugins_state.py
+++ b/tests/pm/test_plugins_state.py
@@ -96,6 +96,7 @@ def test_unreadable_profile_state_is_not_an_empty_selection(homes, monkeypatch,
enabled_member_dirs()
+@pytest.mark.platforms("posix") # a self-referencing symlink needs no privilege only on POSIX
def test_unreadable_config_link_is_not_an_empty_selection(homes):
# Only a missing file means "no config": a symlink loop must refuse, not drop the home's plugins.
default_home, _ = homes
From adebe1f8be653b60abe174c0b0828f897fbb183d Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Fri, 16 Oct 2026 10:20:25 +0400
Subject: [PATCH 032/114] fix(config): gate the readers main added since the
rebase; adopt main's lint rules
Rebasing onto current main brought in direct config.yaml readers that the
seam's reader gate (scripts/check_config_yaml_readers.py) now rejects:
- gateway/session_config.py: the session `config.get mtime` getter uses
config_exists/config_version + the TUI's _version_token, like
tui_gateway/methods_config.py already does.
- hermes_cli/profiles.py: _strip_multiplex_flag checks config_exists.
- hermes_cli/config_backups.py: the byte comparison sits inside the
supports_file_tooling()-gated block; marked like its siblings there.
- hermes_cli/update_receipt.py: _bare_collection_enabled runs in the bare
bootstrap interpreter with no config stack; presence stat marked.
Main also turned on UP006/RUF100/B009 since the branch base: builtin
generics in the seam's new modules, stale noqa comments dropped, and the
newly unused typing imports removed.
---
gateway/session_config.py | 6 ++++--
hermes_cli/backup_restore.py | 6 +++---
hermes_cli/config.py | 4 ++--
hermes_cli/config_backend.py | 16 ++++++++--------
hermes_cli/config_backups.py | 2 +-
hermes_cli/config_issues.py | 3 +--
hermes_cli/config_value_checks.py | 8 ++++----
hermes_cli/profiles.py | 4 ++--
hermes_cli/profiles_cache.py | 4 ++--
hermes_cli/update_receipt.py | 2 +-
scripts/check_config_yaml_readers.py | 2 +-
11 files changed, 29 insertions(+), 28 deletions(-)
diff --git a/gateway/session_config.py b/gateway/session_config.py
index 89dd1e2753..247027f0be 100644
--- a/gateway/session_config.py
+++ b/gateway/session_config.py
@@ -81,9 +81,11 @@ async def config_get(connection, ref, params):
def read():
from gateway.run import _profile_runtime_scope
from hermes_cli.config import load_config
+ from hermes_cli.config_backend import config_exists, config_version
from hermes_cli.config_effective import load_user_config_effective
from hermes_constants import DEFAULT_INDICATOR_STYLE
from tools.approval_context import _get_approval_mode
+ from tui_gateway.methods_config import _version_token
home = Path(connection.authority.profile_id)
with _profile_runtime_scope(home):
cfg = load_config()
@@ -92,8 +94,8 @@ async def config_get(connection, ref, params):
# The user's own settings, not DEFAULT_CONFIG: clients read an absent key as their
# own default (the TUI streams unless display.streaming is explicitly false).
'full': lambda: {'config': _client_config(load_user_config_effective(home / 'config.yaml'))},
- 'mtime': lambda: {'mtime': (home / 'config.yaml').stat().st_mtime
- if (home / 'config.yaml').exists() else 0,
+ 'mtime': lambda: {'mtime': _version_token(config_version(home / 'config.yaml'))
+ if config_exists(home / 'config.yaml') else 0,
# Frozen tool policy is not a live MCP reload request.
'mcp_rev': mcp_revision(policy, cfg)},
'reasoning': lambda: _reasoning(agent, policy, cfg, connection, ref),
diff --git a/hermes_cli/backup_restore.py b/hermes_cli/backup_restore.py
index 81e9f92e0b..b7eada137f 100644
--- a/hermes_cli/backup_restore.py
+++ b/hermes_cli/backup_restore.py
@@ -18,7 +18,7 @@ import tempfile
import zipfile
from contextlib import closing, contextmanager
from pathlib import Path
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any, List, Optional, Tuple
from hermes_state_holders import read_only_db_uri
from hermes_cli.backup_sqlite import _safe_copy_db
@@ -658,7 +658,7 @@ def _import_db_member_exclusive(
pass
-def _get_config_path_value(data: Dict[str, Any], dotted: Tuple[str, ...]) -> Any:
+def _get_config_path_value(data: dict[str, Any], dotted: tuple[str, ...]) -> Any:
node: Any = data
for key in dotted:
if not isinstance(node, dict):
@@ -667,7 +667,7 @@ def _get_config_path_value(data: Dict[str, Any], dotted: Tuple[str, ...]) -> Any
return node
-def _set_config_path_value(data: Dict[str, Any], dotted: Tuple[str, ...], value: Any) -> None:
+def _set_config_path_value(data: dict[str, Any], dotted: tuple[str, ...], value: Any) -> None:
node = data
for key in dotted[:-1]:
child = node.get(key)
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index f7b70160d8..bbf0f32f66 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -44,9 +44,9 @@ from hermes_constants import (
# Re-export from hermes_constants — canonical definition lives there.
from hermes_constants import get_hermes_home, get_process_hermes_home
from utils import atomic_replace, fast_safe_load, file_signature, mkstemp_beside
-from hermes_cli.config_issues import ConfigIssue, _issue # noqa: F401 ConfigIssue re-exported
+from hermes_cli.config_issues import ConfigIssue, _issue
from hermes_cli.config_backend import (
- config_exists, config_version, probe_config_readable, read_config_doc, supports_file_tooling, # noqa: F401 config_exists re-exported for facades
+ config_exists, config_version, probe_config_readable, read_config_doc, supports_file_tooling,
write_config_document)
from hermes_cli.config_read_errors import (
_CONFIG_PARSE_FAILURES, _FIX_PERMS, _FIX_YAML, FailedConfigRead, _backups_dir_display,
diff --git a/hermes_cli/config_backend.py b/hermes_cli/config_backend.py
index 97affbb2ac..ecc679d788 100644
--- a/hermes_cli/config_backend.py
+++ b/hermes_cli/config_backend.py
@@ -20,7 +20,7 @@ from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
-from typing import Any, Mapping, Optional, Protocol, Tuple, Union, runtime_checkable
+from typing import Any, Mapping, Optional, Protocol, Union, runtime_checkable
CONFIG_FILENAME = "config.yaml"
BACKEND_ENV = "HERMES_CONFIG_BACKEND"
@@ -32,7 +32,7 @@ PathLike = Union[str, "os.PathLike[str]"]
class UserLayer:
"""One read of a profile's user layer."""
doc: Any # the parsed root as written (None for an empty document)
- version: Tuple[Any, ...] # the cache signature this doc was read at (etag for a remote backend)
+ version: tuple[Any, ...] # the cache signature this doc was read at (etag for a remote backend)
locks: Mapping[str, str] = field(default_factory=dict) # dotted prefix -> level that locks it
provenance: str = "" # where the doc came from, for diagnostics
@@ -47,7 +47,7 @@ class Changes:
"""
document: Optional[dict] = None
set: Mapping[str, Any] = field(default_factory=dict)
- unset: Tuple[str, ...] = ()
+ unset: tuple[str, ...] = ()
extra_content_on_create: Optional[str] = None
@@ -61,7 +61,7 @@ class ConfigBackend(Protocol):
"""``read_user_layer(home).doc`` re-read only when ``version`` changes; never mutate it."""
...
- def version(self, home: Path) -> Tuple[Any, ...]:
+ def version(self, home: Path) -> tuple[Any, ...]:
"""Cheap cache signature; changes whenever the user layer does."""
...
@@ -93,7 +93,7 @@ class FileBackend:
# Path-level primitives: the file backend's whole behaviour, also used for explicit files
# that are not a home's config.yaml (see ``_route``).
@staticmethod
- def version_path(path: Path) -> Tuple[int, int, int, int]:
+ def version_path(path: Path) -> tuple[int, int, int, int]:
st = path.stat() # before the import: a missing file must not need the utils stack (PM runtime)
from utils import file_signature
return file_signature(st)
@@ -130,7 +130,7 @@ class FileBackend:
def read_user_doc_readonly(self, home: Path) -> Any:
return self.read_path_readonly(self.config_path(home))
- def version(self, home: Path) -> Tuple[int, int, int, int]:
+ def version(self, home: Path) -> tuple[int, int, int, int]:
return self.version_path(self.config_path(home))
def exists(self, home: Path) -> bool:
@@ -196,7 +196,7 @@ class _ExplicitFile(FileBackend):
return self._path
-def _route(config_path: PathLike) -> Tuple[ConfigBackend, Path]:
+def _route(config_path: PathLike) -> tuple[ConfigBackend, Path]:
"""``(backend, home)`` serving a config path through the one ``ConfigBackend`` surface.
``/config.yaml`` is that home's user layer and goes to the selected backend. Any other
@@ -220,7 +220,7 @@ def read_config_doc_readonly(config_path: PathLike) -> Any:
return backend.read_user_doc_readonly(home)
-def config_version(config_path: PathLike) -> Tuple[Any, ...]:
+def config_version(config_path: PathLike) -> tuple[Any, ...]:
"""Cache signature of a config file; raises ``FileNotFoundError`` / ``OSError`` like ``stat``."""
backend, home = _route(config_path)
return backend.version(home)
diff --git a/hermes_cli/config_backups.py b/hermes_cli/config_backups.py
index 1649a0dcf1..0fd922aee4 100644
--- a/hermes_cli/config_backups.py
+++ b/hermes_cli/config_backups.py
@@ -60,7 +60,7 @@ def backup_config(config_path: Path, reason: str, *, keep: int = DEFAULT_KEEP) -
existing = list_config_backups(config_path, reason)
# Not filecmp.cmp: its process-wide cache keys on (size, mtime), so an equal-size edit
# within one mtime tick of an earlier comparison is reported identical and never backed up.
- if existing and config_path.read_bytes() == existing[0].read_bytes():
+ if existing and config_path.read_bytes() == existing[0].read_bytes(): # config-reader: ok — file tooling, gated above
return None
dest = root / f"{config_path.name}.{reason}.{time.strftime('%Y%m%d-%H%M%S')}"
if dest.is_symlink() or dest.exists(): # never write through a planted link
diff --git a/hermes_cli/config_issues.py b/hermes_cli/config_issues.py
index 1298d35f51..211a850c29 100644
--- a/hermes_cli/config_issues.py
+++ b/hermes_cli/config_issues.py
@@ -4,7 +4,6 @@ Its own module so the facade (``hermes_cli.config``) and its check siblings shar
a facade import from the siblings."""
from dataclasses import dataclass
-from typing import List
@dataclass
@@ -15,5 +14,5 @@ class ConfigIssue:
hint: str
-def _issue(issues: List[ConfigIssue], severity: str, message: str, hint: str) -> None:
+def _issue(issues: list[ConfigIssue], severity: str, message: str, hint: str) -> None:
issues.append(ConfigIssue(severity, message, hint))
diff --git a/hermes_cli/config_value_checks.py b/hermes_cli/config_value_checks.py
index 6b21c27acf..14c5bd5ad8 100644
--- a/hermes_cli/config_value_checks.py
+++ b/hermes_cli/config_value_checks.py
@@ -1,12 +1,12 @@
"""Value checks for single config sections (voice, timezone, web backends), run by
``hermes_cli.config.validate_config_structure`` for doctor and the startup warnings."""
-from typing import Any, Dict, List
+from typing import Any
from hermes_cli.config_issues import ConfigIssue, _issue
-def _validate_voice(config: Dict[str, Any], issues: List[ConfigIssue]) -> None:
+def _validate_voice(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
voice_cfg = config.get("voice")
if not (isinstance(voice_cfg, dict) and "submit_mode" in voice_cfg):
return
@@ -17,7 +17,7 @@ def _validate_voice(config: Dict[str, Any], issues: List[ConfigIssue]) -> None:
"Set voice.submit_mode to direct (submit immediately) or draft (edit before sending)")
-def _validate_timezone(config: Dict[str, Any], issues: List[ConfigIssue]) -> None:
+def _validate_timezone(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
"""``timezone`` must be an IANA name the runtime can load.
``hermes_time._get_zoneinfo()`` swallows an invalid name behind a single WARNING in the
@@ -50,7 +50,7 @@ def _validate_timezone(config: Dict[str, Any], issues: List[ConfigIssue]) -> Non
_issue(issues, "error", f"timezone {name!r} is not a valid IANA zone name", hint)
-def _validate_web_backends(config: Dict[str, Any], issues: List[ConfigIssue]) -> None:
+def _validate_web_backends(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
"""A stale web backend selection otherwise fails only at the first web_search/web_extract
call with a generic "no registered provider" error; warn at startup instead."""
# See #99199.
diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py
index 13c5f745f6..6055eb87f7 100644
--- a/hermes_cli/profiles.py
+++ b/hermes_cli/profiles.py
@@ -25,7 +25,7 @@ from hermes_constants import (
named_profile_has_identity, named_profile_is_deleted, named_profile_is_live,
)
from hermes_cli.config_backend import config_exists, config_version, require_file_tooling
-from hermes_cli.profiles_cache import _PROFILE_FILE_CACHE, _cached_profile_read, _config_version_or_none # noqa: F401 _PROFILE_FILE_CACHE: tests clear it here
+from hermes_cli.profiles_cache import _PROFILE_FILE_CACHE, _cached_profile_read, _config_version_or_none
logger = logging.getLogger(__name__)
@@ -1262,7 +1262,7 @@ def _clone_all_into(source_dir: Path, profile_dir: Path, canon: str) -> Optional
def _strip_multiplex_flag(config_path: Path) -> None:
"""Multiplexing belongs to the default profile; a clone that keeps the flag would start its
own multiplexer (or be refused at boot) the first time it runs a gateway."""
- if not config_path.is_file():
+ if not config_exists(config_path):
return
with contextlib.suppress(Exception): # creation must not fail over an unreadable copy
from hermes_cli.config import atomic_config_replace, read_user_config_raw
diff --git a/hermes_cli/profiles_cache.py b/hermes_cli/profiles_cache.py
index 3a80963c0c..ea5599b843 100644
--- a/hermes_cli/profiles_cache.py
+++ b/hermes_cli/profiles_cache.py
@@ -1,7 +1,7 @@
"""The per-profile derived-value cache behind ``list_profiles`` (``hermes_cli.profiles``)."""
from pathlib import Path
-from typing import Dict, Optional
+from typing import Optional
from hermes_cli.config_backend import config_version
@@ -12,7 +12,7 @@ from hermes_cli.config_backend import config_version
# template, ~119KB) was re-parsed for every bot every five seconds to yield the same two strings.
# Only DERIVED values are cached, never a document a caller could write back: the raw readers
# (`read_user_config_raw`, `_load_yaml_dict`) keep their uncached contract. See #117378.
-_PROFILE_FILE_CACHE: Dict[tuple, tuple] = {}
+_PROFILE_FILE_CACHE: dict[tuple, tuple] = {}
_PROFILE_FILE_CACHE_MAX = 512
diff --git a/hermes_cli/update_receipt.py b/hermes_cli/update_receipt.py
index f502b51113..97748b9036 100644
--- a/hermes_cli/update_receipt.py
+++ b/hermes_cli/update_receipt.py
@@ -807,7 +807,7 @@ def _bare_collection_enabled() -> Optional[bool]:
from hermes_constants import get_hermes_home
try:
- (get_hermes_home() / "config.yaml").stat()
+ (get_hermes_home() / "config.yaml").stat() # config-reader: ok — bare interpreter has no config stack; presence probe only
except FileNotFoundError:
return False
except OSError:
diff --git a/scripts/check_config_yaml_readers.py b/scripts/check_config_yaml_readers.py
index 5816ef4cb2..86038e00ee 100644
--- a/scripts/check_config_yaml_readers.py
+++ b/scripts/check_config_yaml_readers.py
@@ -105,7 +105,7 @@ class _Scanner:
if start is None or end is None:
return ""
# col offsets are UTF-8 byte offsets.
- c0, c1 = getattr(node, "col_offset"), getattr(node, "end_col_offset")
+ c0, c1 = node.col_offset, node.end_col_offset
b = self.blines
if start == end:
return b[start - 1][c0:c1].decode("utf-8", "replace")
From 2f6ae9a0156d7af9520c7c80e86f2e1c3f401756 Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Fri, 16 Oct 2026 10:44:59 +0400
Subject: [PATCH 033/114] fix(config): an empty HERMES_CONFIG_BACKEND in .env
resets a stale shell value
load_hermes_dotenv() loads the launch home's .env with override, so a
`HERMES_CONFIG_BACKEND=` line replaces a shell `remote` with "" (the file
default). The pre-dotenv selector used `override or kind`, which dropped
that empty override and refused to start, where the process after the
dotenv load would run on the file backend. Apply any override that is
present (not None), then treat "" as file.
---
hermes_cli/config_backend.py | 3 ++-
tests/hermes_cli/test_config_backend.py | 7 +++++++
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/hermes_cli/config_backend.py b/hermes_cli/config_backend.py
index ecc679d788..cc277d2b3f 100644
--- a/hermes_cli/config_backend.py
+++ b/hermes_cli/config_backend.py
@@ -175,7 +175,8 @@ def get_config_backend() -> ConfigBackend:
if kind != "file":
# A config read can run before load_hermes_dotenv() (hermes_cli.config reads at import time):
# refuse only a value the launch home's .env / managed .env do not override, as that load will.
- kind = (_dotenv_backend_override() or kind).strip().lower() or "file"
+ override = _dotenv_backend_override() # None = no .env sets it; "" = an explicit reset to file
+ kind = (kind if override is None else override).strip().lower() or "file"
if kind == "file":
return _FILE_BACKEND
if kind == "remote":
diff --git a/tests/hermes_cli/test_config_backend.py b/tests/hermes_cli/test_config_backend.py
index aaddad32d0..3d8c292a6a 100644
--- a/tests/hermes_cli/test_config_backend.py
+++ b/tests/hermes_cli/test_config_backend.py
@@ -41,6 +41,13 @@ class TestSelection:
(home / ".env").write_text(f"{cb.BACKEND_ENV}=file\n", encoding="utf-8")
assert cb.get_config_backend().name == "file"
+ def test_empty_launch_env_value_resets_a_stale_shell_value_to_file(self, home, monkeypatch):
+ # ``HERMES_CONFIG_BACKEND=`` in the launch .env loads over the shell value as "", i.e. the default.
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ monkeypatch.setenv(cb.BACKEND_ENV, "remote")
+ (home / ".env").write_text(f"{cb.BACKEND_ENV}=\n", encoding="utf-8")
+ assert cb.get_config_backend().name == "file"
+
def test_explicit_non_config_file_ignores_backend(self, home, monkeypatch):
other = home / "import-source.yaml"
other.write_text("a: 1\n", encoding="utf-8")
From cd5375ac8beb02828a1bc80ff8697a42e62f0d61 Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Fri, 16 Oct 2026 10:52:31 +0400
Subject: [PATCH 034/114] fix(profile): an unparseable config never fails the
post-clone memory notice
`_load_config_dict` replaced main's `profiles._load_yaml_dict` (any
Exception -> None) but caught only YAMLError/OSError/UnicodeError. The
YAML constructors raise a bare ValueError for values such as
`2024-02-30` or `!!int abc`, so a clone of such a profile printed a
traceback from `hermes profile create` after the profile was already
made. Catch ValueError (which also covers UnicodeDecodeError).
ConfigBackendUnavailable still propagates: it is a SystemExit.
---
hermes_cli/profile_memory_config.py | 4 ++-
.../hermes_cli/test_profile_memory_config.py | 30 +++++++++++++++++++
2 files changed, 33 insertions(+), 1 deletion(-)
create mode 100644 tests/hermes_cli/test_profile_memory_config.py
diff --git a/hermes_cli/profile_memory_config.py b/hermes_cli/profile_memory_config.py
index c9f87d8a9e..9725cc056d 100644
--- a/hermes_cli/profile_memory_config.py
+++ b/hermes_cli/profile_memory_config.py
@@ -67,7 +67,9 @@ def _load_config_dict(profile_dir: Path) -> Optional[dict]:
from hermes_cli.config_backend import read_config_doc
try:
data = read_config_doc(profile_dir / "config.yaml") or {}
- except (YAMLError, OSError, UnicodeError): # FileNotFoundError included: no config
+ # FileNotFoundError included (no config). ValueError: the YAML constructors raise it bare for
+ # values like ``2024-02-30`` / ``!!int abc``; this read is a best-effort notice and never fails a clone.
+ except (YAMLError, OSError, ValueError):
return None
return data if isinstance(data, dict) else None
diff --git a/tests/hermes_cli/test_profile_memory_config.py b/tests/hermes_cli/test_profile_memory_config.py
new file mode 100644
index 0000000000..ba515b6de6
--- /dev/null
+++ b/tests/hermes_cli/test_profile_memory_config.py
@@ -0,0 +1,30 @@
+"""cloned_memory_provider: a best-effort post-clone notice that never fails ``hermes profile create``."""
+
+import pytest
+
+from hermes_cli.profile_memory_config import cloned_memory_provider
+
+
+@pytest.mark.parametrize("body", [
+ "memory:\n provider: honcho\nwhen: 2024-02-30\n", # YAML constructor raises a bare ValueError
+ "memory: [unclosed\n", # YAMLError
+ b"memory:\n provider: \xff\n", # UnicodeDecodeError
+])
+def test_unreadable_config_means_no_notice(tmp_path, body):
+ (tmp_path / "honcho").mkdir()
+ path = tmp_path / "config.yaml"
+ if isinstance(body, bytes):
+ path.write_bytes(body)
+ else:
+ path.write_text(body, encoding="utf-8")
+ assert cloned_memory_provider(tmp_path) is None
+
+
+def test_missing_config_means_no_notice(tmp_path):
+ assert cloned_memory_provider(tmp_path) is None
+
+
+def test_configured_provider_with_copied_state_is_named(tmp_path):
+ (tmp_path / "honcho").mkdir()
+ (tmp_path / "config.yaml").write_text("memory:\n provider: honcho\n", encoding="utf-8")
+ assert cloned_memory_provider(tmp_path) == "honcho"
From e39618f448d1b21a78f1762dbfb2442df7a821e7 Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Fri, 16 Oct 2026 10:56:54 +0400
Subject: [PATCH 035/114] refactor(config): one public version helper pair in
config_backend
Review of the rebased stack found the same "version, or None when the
config is absent" shape written out at three call sites
(`config_version(p) if config_exists(p) else None`), a private
try/except copy of it in profiles_cache, and the gateway importing the
TUI's private `_version_token` from tui_gateway.methods_config.
- config_backend gains `config_version_or_none()` (one backend call, no
exists-then-stat race) and `version_token()`, moved from
tui_gateway/methods_config.py unchanged.
- tui_gateway (methods_config, server), the classic CLI config watcher,
gateway/session_config and the profile roster cache use them;
profiles_cache drops its private copy.
No behaviour change: same version tuples, same numeric token.
---
gateway/session_config.py | 6 ++----
hermes_cli/cli_tui_mixin.py | 4 ++--
hermes_cli/config_backend.py | 19 +++++++++++++++++++
hermes_cli/profiles.py | 7 ++++---
hermes_cli/profiles_cache.py | 10 ----------
tests/hermes_cli/test_config_backend.py | 11 +++++++++++
.../hermes_cli/test_profile_listing_cache.py | 4 ++--
tui_gateway/methods_config.py | 18 ++----------------
tui_gateway/server.py | 4 ++--
9 files changed, 44 insertions(+), 39 deletions(-)
diff --git a/gateway/session_config.py b/gateway/session_config.py
index 247027f0be..ef1a09b5e3 100644
--- a/gateway/session_config.py
+++ b/gateway/session_config.py
@@ -81,11 +81,10 @@ async def config_get(connection, ref, params):
def read():
from gateway.run import _profile_runtime_scope
from hermes_cli.config import load_config
- from hermes_cli.config_backend import config_exists, config_version
+ from hermes_cli.config_backend import config_version_or_none, version_token
from hermes_cli.config_effective import load_user_config_effective
from hermes_constants import DEFAULT_INDICATOR_STYLE
from tools.approval_context import _get_approval_mode
- from tui_gateway.methods_config import _version_token
home = Path(connection.authority.profile_id)
with _profile_runtime_scope(home):
cfg = load_config()
@@ -94,8 +93,7 @@ async def config_get(connection, ref, params):
# The user's own settings, not DEFAULT_CONFIG: clients read an absent key as their
# own default (the TUI streams unless display.streaming is explicitly false).
'full': lambda: {'config': _client_config(load_user_config_effective(home / 'config.yaml'))},
- 'mtime': lambda: {'mtime': _version_token(config_version(home / 'config.yaml'))
- if config_exists(home / 'config.yaml') else 0,
+ 'mtime': lambda: {'mtime': version_token(config_version_or_none(home / 'config.yaml')),
# Frozen tool policy is not a live MCP reload request.
'mcp_rev': mcp_revision(policy, cfg)},
'reasoning': lambda: _reasoning(agent, policy, cfg, connection, ref),
diff --git a/hermes_cli/cli_tui_mixin.py b/hermes_cli/cli_tui_mixin.py
index 66cc4c4497..e3d27250b9 100644
--- a/hermes_cli/cli_tui_mixin.py
+++ b/hermes_cli/cli_tui_mixin.py
@@ -1817,7 +1817,7 @@ class CLITuiMixin:
def _tui_init_run_state(self):
"""Reset the per-run REPL state (queues, modal states, voice state, config watcher)."""
- from hermes_cli.config_backend import config_exists, config_version
+ from hermes_cli.config_backend import config_version_or_none
self._agent_running = False
self._pending_input = queue.Queue() # normal input (commands + new queries)
self._interrupt_queue = queue.Queue() # messages typed while the agent is running
@@ -1837,7 +1837,7 @@ class CLITuiMixin:
# Config file watcher — detect mcp_servers changes and auto-reload.
from hermes_cli.config import get_config_path as _get_config_path
_cfg_path = _get_config_path()
- self._config_sig: tuple | None = config_version(_cfg_path) if config_exists(_cfg_path) else None
+ self._config_sig: tuple | None = config_version_or_none(_cfg_path)
self._config_mcp_servers: dict = self.config.get("mcp_servers") or {}
self._last_config_check: float = 0.0 # monotonic time of last check
diff --git a/hermes_cli/config_backend.py b/hermes_cli/config_backend.py
index cc277d2b3f..dfbed76be5 100644
--- a/hermes_cli/config_backend.py
+++ b/hermes_cli/config_backend.py
@@ -227,6 +227,25 @@ def config_version(config_path: PathLike) -> tuple[Any, ...]:
return backend.version(home)
+def config_version_or_none(config_path: PathLike) -> Optional[tuple[Any, ...]]:
+ """:func:`config_version`, or None when the config is absent or cannot be stat'ed."""
+ try:
+ return config_version(config_path)
+ except OSError:
+ return None
+
+
+def version_token(version: Optional[tuple[Any, ...]]) -> float:
+ """A number that changes with a backend ``version`` (0 = no config), for clients that only
+ compare successive values: the file backend's version leads with ``st_mtime_ns``, kept as seconds."""
+ if not version:
+ return 0
+ if isinstance(version[0], int):
+ return version[0] / 1_000_000_000
+ import zlib
+ return float(zlib.crc32(repr(version).encode("utf-8")) or 1)
+
+
def probe_config_readable(config_path: PathLike) -> None:
"""Raise ``OSError`` while a local config file cannot be opened; no parse. A non-file user
layer has no local read to fail."""
diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py
index 6055eb87f7..28af9e57f5 100644
--- a/hermes_cli/profiles.py
+++ b/hermes_cli/profiles.py
@@ -24,8 +24,8 @@ from hermes_constants import (
LOCAL_RUNTIME_ROOT_DIRS, PROFILE_ID_RE, clear_named_profile_deleted, mark_named_profile_deleted,
named_profile_has_identity, named_profile_is_deleted, named_profile_is_live,
)
-from hermes_cli.config_backend import config_exists, config_version, require_file_tooling
-from hermes_cli.profiles_cache import _PROFILE_FILE_CACHE, _cached_profile_read, _config_version_or_none
+from hermes_cli.config_backend import config_exists, config_version, config_version_or_none, require_file_tooling
+from hermes_cli.profiles_cache import _PROFILE_FILE_CACHE, _cached_profile_read
logger = logging.getLogger(__name__)
@@ -737,7 +737,8 @@ def _read_config_model(profile_dir: Path) -> tuple:
pass
return None, None
- return _cached_profile_read(config_path, "config-model", _read, _config_version_or_none)
+ # The user layer changes through the config backend, not necessarily on local disk.
+ return _cached_profile_read(config_path, "config-model", _read, config_version_or_none)
def _seed_model_config(profile_dir: Path) -> None:
diff --git a/hermes_cli/profiles_cache.py b/hermes_cli/profiles_cache.py
index ea5599b843..3511129944 100644
--- a/hermes_cli/profiles_cache.py
+++ b/hermes_cli/profiles_cache.py
@@ -3,7 +3,6 @@
from pathlib import Path
from typing import Optional
-from hermes_cli.config_backend import config_version
# (path, kind) -> (file signature, the small derived value). `list_profiles` re-reads three YAML
@@ -26,15 +25,6 @@ def _profile_file_signature(path: Path) -> Optional[tuple]:
return (stat.st_mtime_ns, stat.st_size, stat.st_ino)
-def _config_version_or_none(path: Path) -> Optional[tuple]:
- """The config backend's version of a profile's config.yaml (None when absent): the user layer
- changes through the backend, not necessarily on local disk."""
- try:
- return config_version(path)
- except OSError:
- return None
-
-
def _cached_profile_read(path: Path, kind: str, compute, signature_of=_profile_file_signature):
"""``compute()``'s value, reused while ``signature_of(path)`` is unchanged. A missing file is never
cached: reading it costs nothing, and one created later must be picked up."""
diff --git a/tests/hermes_cli/test_config_backend.py b/tests/hermes_cli/test_config_backend.py
index 3d8c292a6a..f9a78566bc 100644
--- a/tests/hermes_cli/test_config_backend.py
+++ b/tests/hermes_cli/test_config_backend.py
@@ -65,6 +65,17 @@ class TestFileBackend:
with pytest.raises(FileNotFoundError):
cb.read_config_doc(path)
+ def test_version_or_none_and_token(self, home):
+ path = home / "config.yaml"
+ assert cb.config_version_or_none(path) is None
+ assert cb.version_token(None) == 0
+ path.write_text("a: 1\n", encoding="utf-8")
+ version = cb.config_version_or_none(path)
+ assert version == cb.config_version(path)
+ assert cb.version_token(version) == version[0] / 1_000_000_000 > 0
+ # An opaque non-file version still yields a stable non-zero number.
+ assert cb.version_token(("etag-1",)) == cb.version_token(("etag-1",)) != 0
+
def test_read_version_and_bom(self, home):
path = home / "config.yaml"
path.write_bytes("\ufeffmodel:\n default: x\n".encode("utf-8"))
diff --git a/tests/hermes_cli/test_profile_listing_cache.py b/tests/hermes_cli/test_profile_listing_cache.py
index e0c7a409cb..e527698732 100644
--- a/tests/hermes_cli/test_profile_listing_cache.py
+++ b/tests/hermes_cli/test_profile_listing_cache.py
@@ -88,11 +88,11 @@ def test_the_raw_config_reader_keeps_its_uncached_contract(profile_dir):
def test_config_model_cache_is_keyed_on_the_backend_version_not_the_local_file(profile_dir, monkeypatch):
"""The user layer changes through the config backend, which need not touch the local file."""
- from hermes_cli import config_backend, profiles_cache
+ from hermes_cli import config_backend
assert profiles._read_config_model(profile_dir) == ("gpt-4o", "openai")
version = [("v2",)]
- monkeypatch.setattr(profiles_cache, "config_version", lambda path: version[0])
+ monkeypatch.setattr(config_backend, "config_version", lambda path: version[0])
monkeypatch.setattr(config_backend.FileBackend, "read_user_layer", lambda self, home: config_backend.UserLayer(
doc={"model": {"provider": "anthropic", "default": "claude-opus-4.6"}}, version=version[0]))
diff --git a/tui_gateway/methods_config.py b/tui_gateway/methods_config.py
index 66d0aa2c17..3148436fb8 100644
--- a/tui_gateway/methods_config.py
+++ b/tui_gateway/methods_config.py
@@ -243,24 +243,10 @@ def _cfg_get_thinking_mode(params):
return {"value": raw}
-def _version_token(version) -> float:
- """A non-zero number that changes with ``version`` (0 = no config yet, as before)."""
- if not version:
- return 0
- if isinstance(version[0], int):
- return version[0] / 1_000_000_000
- import zlib
- return float(zlib.crc32(repr(version).encode("utf-8")) or 1)
-
-
def _cfg_get_mtime(params):
- from hermes_cli.config_backend import config_exists, config_version
- cfg_path = _hermes_home / "config.yaml"
+ from hermes_cli.config_backend import config_version_or_none, version_token
try:
- # The client only compares successive values for change: a numeric token derived from the
- # backend's opaque version (the file backend's leads with st_mtime_ns, kept as seconds).
- version = config_version(cfg_path) if config_exists(cfg_path) else None
- mtime = _version_token(version)
+ mtime = version_token(config_version_or_none(_hermes_home / "config.yaml"))
except Exception:
return {"mtime": 0}
# mcp_rev: hash of the MCP-relevant sections so the poller reloads MCP servers only when
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 7469d779e2..cf1aa513c0 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -998,12 +998,12 @@ def _load_cfg_raw() -> dict:
expansion applied here would be persisted on the next save). Behavioral reads use :func:`_load_cfg`.
Cache keyed on the resolved path so profiles don't clobber."""
global _cfg_cache, _cfg_sig, _cfg_path
- from hermes_cli.config_backend import config_exists, config_version
+ from hermes_cli.config_backend import config_exists, config_version_or_none
from hermes_cli.config import read_user_config_raw
from hermes_cli.config_read_errors import FailedConfigRead
try:
p = _active_config_path()
- sig = config_version(p) if config_exists(p) else None
+ sig = config_version_or_none(p)
with _cfg_lock:
if _cfg_cache is not None and _cfg_sig == sig and _cfg_path == p:
return copy.deepcopy(_cfg_cache)
From b4a39efe8cf9553b70a5aa8799fd9418d4413ebc Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Fri, 16 Oct 2026 10:58:34 +0400
Subject: [PATCH 036/114] refactor(config): gateway and xAI migration use the
seam's path helpers
- gateway/config_loader.read_yaml_layers was the only production caller
that reached into the backend object (get_config_backend(),
backend.exists(home), backend.read_user_layer(home).doc). It now uses
config_exists/read_config_doc like every other reader; same backend
calls underneath, so the protocol stays internal to config_backend.
- xai_retirement hand-rolled `if not supports_file_tooling(): raise
ConfigBackendUnavailable(...)`, which is require_file_tooling().
---
gateway/config_loader.py | 8 ++++----
hermes_cli/xai_retirement.py | 8 +++-----
2 files changed, 7 insertions(+), 9 deletions(-)
diff --git a/gateway/config_loader.py b/gateway/config_loader.py
index 4081ff1c64..26832e6163 100644
--- a/gateway/config_loader.py
+++ b/gateway/config_loader.py
@@ -493,12 +493,12 @@ def read_yaml_layers(home: Path) -> dict:
(``gateway.relay.relay_explicitly_disabled``) reads through here so it cannot disagree with
``load_gateway_config()`` on which files count.
"""
- from hermes_cli.config_backend import get_config_backend
+ from hermes_cli.config_backend import config_exists, read_config_doc
- backend = get_config_backend()
+ config_path = Path(home) / "config.yaml"
yaml_cfg: dict = {}
- if backend.exists(home):
- yaml_cfg = backend.read_user_layer(home).doc or {}
+ if config_exists(config_path):
+ yaml_cfg = read_config_doc(config_path) or {}
from hermes_cli.config import _expand_env_vars
diff --git a/hermes_cli/xai_retirement.py b/hermes_cli/xai_retirement.py
index 8b62aeccbf..43a2837f64 100644
--- a/hermes_cli/xai_retirement.py
+++ b/hermes_cli/xai_retirement.py
@@ -131,11 +131,9 @@ def apply_migration(
Unless ``backup=False`` a copy goes to ``backups/config/`` (reason ``pre-migrate-xai``).
"""
from ruamel.yaml import YAML # local import — avoid hard dep at module load
- from hermes_cli.config_backend import ConfigBackendUnavailable, supports_file_tooling
- if not supports_file_tooling():
- # A type-literal-preserving rewrite of the file itself; a non-file backend has no file.
- raise ConfigBackendUnavailable("the xAI model migration rewrites the local config.yaml, and "
- "this config backend has none")
+ from hermes_cli.config_backend import require_file_tooling
+ # A type-literal-preserving rewrite of the file itself; a non-file backend has no file.
+ require_file_tooling("The xAI model migration")
config_path = Path(config_path)
if not config_path.exists(): # config-reader: ok — file tooling, gated on supports_file_tooling()
raise FileNotFoundError(config_path)
From 32ea245ded50aa921a01f6e535675c2366892879 Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Fri, 16 Oct 2026 11:02:04 +0400
Subject: [PATCH 037/114] docs(config): document HERMES_CONFIG_BACKEND; correct
the hermes_time fallback comment
- The environment-variable reference had no row for HERMES_CONFIG_BACKEND,
a selector that stops Hermes at startup when set to anything but
`file`. Added, with where it is read from and a link to the developer
guide.
- hermes_time called the config backend module "dependency-free"; its
file backend imports utils. Say what the fallback actually does.
---
hermes_time.py | 4 ++--
website/docs/reference/environment-variables.md | 1 +
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/hermes_time.py b/hermes_time.py
index b8bf20e3f5..00f1362cc3 100644
--- a/hermes_time.py
+++ b/hermes_time.py
@@ -89,8 +89,8 @@ def _resolve_timezone_name() -> str:
# Prefer the shared cached effective-config loader (mtime-keyed + libyaml, managed overlay
# included so an administrator can pin ``timezone``): a direct safe_load of a large
# config.yaml costs ~100 ms and this ran inside the FIRST system prompt build. The bare
- # parse (through the dependency-free config backend module) is the fallback when the
- # effective loader cannot be imported.
+ # parse through the config backend is the fallback when the effective loader cannot be
+ # imported (any failure there lands in the outer except: no configured timezone).
try:
from hermes_cli.config_effective import load_user_config_effective
cfg = load_user_config_effective(get_config_path())
diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md
index dba4330261..88a3094b39 100644
--- a/website/docs/reference/environment-variables.md
+++ b/website/docs/reference/environment-variables.md
@@ -125,6 +125,7 @@ Hermes reads environment variables from the process environment and, for user-ma
| `HERMES_LOCAL_STT_COMMAND` | Optional local speech-to-text command template. Supports `{input_path}`, `{output_dir}`, `{language}`, and `{model}` placeholders |
| `HERMES_LOCAL_STT_LANGUAGE` | Default language hint for STT. Used by the `local` (faster-whisper) provider, `HERMES_LOCAL_STT_COMMAND`, the local `whisper` CLI fallback (default: `en`), Groq, and xAI when no per-provider `language` is set in `config.yaml` |
| `HERMES_HOME` | Select the configuration and user-data home. A literal `~` or `$VAR` in the value is expanded (fish does not expand `~` inside `VAR=~/…`), so it never resolves relative to the current directory. Defaults to `~/.hermes` on POSIX and `%LOCALAPPDATA%\hermes` on Windows; the official Docker image uses `/opt/data`. Profile/runtime context can select a more specific home. |
+| `HERMES_CONFIG_BACKEND` | Select where the profile's `config.yaml` user layer is read and written. Only `file` (the default, the local `config.yaml`) exists in this build; any other value stops Hermes at startup with an error instead of falling back to defaults. Read from the launch environment, the launch home's `.env` and the managed `.env`, never from `config.yaml`. See [Config backend](../developer-guide/config-backend.md). |
| `HERMES_DISABLE_WINDOWS_UTF8` | **Windows only.** Set to `1` to disable the UTF-8 stdio shim (`configure_windows_stdio()`) and fall back to the console's locale code page. Useful for bisecting encoding bugs; rarely the right setting in normal operation |
| `HERMES_KANBAN_HOME` | Override the shared Hermes root that anchors the kanban board (db + workspaces + worker logs). Falls back to `get_default_hermes_root()` (the parent of any active profile). Useful for tests and unusual deployments |
| `HERMES_KANBAN_BOARD` | Pin the active kanban board for this process. Takes precedence over `~/.hermes/kanban/current`; the dispatcher injects this into worker subprocess env so workers physically cannot see tasks on other boards. Defaults to `default`. Slug validation: lowercase alphanumerics + hyphens + underscores, 1-64 chars |
From 7651d401447ec52b5294ac9e283d2122dad9d7b4 Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Fri, 16 Oct 2026 11:07:18 +0400
Subject: [PATCH 038/114] refactor(config): the remaining version-or-None sites
use config_version_or_none
Round-2 review of the fold found three more hand-written copies of the
helper the previous commit made public: the classic CLI config watcher
(exists check + try/config_version/except OSError, every 5 s; it compares
against the `_config_sig` the TUI mixin now seeds with the helper), the
MoA preset cache stamp, and skill_utils' raw-config cache key. Same
results, one stat fewer per watcher tick. The version_token test now
also asserts that the token changes when an opaque version changes.
---
agent/moa_loop.py | 7 ++-----
agent/skill_utils.py | 8 +++-----
hermes_cli/cli_info_mixin.py | 9 +++------
tests/hermes_cli/test_config_backend.py | 1 +
4 files changed, 9 insertions(+), 16 deletions(-)
diff --git a/agent/moa_loop.py b/agent/moa_loop.py
index ff0d140037..d33b46a6d2 100644
--- a/agent/moa_loop.py
+++ b/agent/moa_loop.py
@@ -126,11 +126,8 @@ def _resolve_preset_cached(preset_name: str) -> tuple[dict[str, Any], Any]:
(skips resolve_moa_preset's full validation of the moa block on every create())."""
from hermes_cli.config import get_config_path, load_config
from hermes_cli.moa_config import resolve_moa_preset
- from hermes_cli.config_backend import config_version
- try:
- cfg_stamp = config_version(get_config_path())
- except OSError:
- cfg_stamp = None
+ from hermes_cli.config_backend import config_version_or_none
+ cfg_stamp = config_version_or_none(get_config_path())
moa_raw = load_config().get("moa") or {}
key = (cfg_stamp, preset_name)
with _preset_cache_lock:
diff --git a/agent/skill_utils.py b/agent/skill_utils.py
index a829436603..03c087c4ed 100644
--- a/agent/skill_utils.py
+++ b/agent/skill_utils.py
@@ -210,11 +210,9 @@ def _raw_config_cache_clear() -> None:
def _config_cache_key(config_path: Path) -> Optional[tuple[str, int, int, int, int]]:
"""``(path, *backend version)`` identity of config.yaml, or None when unreadable/absent."""
- try:
- from hermes_cli.config_backend import config_version
- return (str(config_path), *config_version(config_path))
- except OSError:
- return None
+ from hermes_cli.config_backend import config_version_or_none
+ version = config_version_or_none(config_path)
+ return None if version is None else (str(config_path), *version)
def _load_raw_config() -> dict[str, Any]:
diff --git a/hermes_cli/cli_info_mixin.py b/hermes_cli/cli_info_mixin.py
index 772946da56..f2a0e49fa6 100644
--- a/hermes_cli/cli_info_mixin.py
+++ b/hermes_cli/cli_info_mixin.py
@@ -864,7 +864,7 @@ class CLIInfoMixin:
cache** (the next message re-sends the full input prefix, expensive on long-context / high-reasoning
models). See #1474.
"""
- from hermes_cli.config_backend import config_exists, config_version, read_config_doc
+ from hermes_cli.config_backend import config_version_or_none, read_config_doc
now = time.monotonic()
if now - self._last_config_check < CONFIG_WATCH_INTERVAL:
@@ -873,11 +873,8 @@ class CLIInfoMixin:
from hermes_cli.config import get_config_path as _get_config_path
cfg_path = _get_config_path()
- if not config_exists(cfg_path):
- return
- try:
- sig = config_version(cfg_path)
- except OSError:
+ sig = config_version_or_none(cfg_path)
+ if sig is None:
return
if sig == self._config_sig:
return # unchanged — fast path
diff --git a/tests/hermes_cli/test_config_backend.py b/tests/hermes_cli/test_config_backend.py
index f9a78566bc..4bded8a61f 100644
--- a/tests/hermes_cli/test_config_backend.py
+++ b/tests/hermes_cli/test_config_backend.py
@@ -75,6 +75,7 @@ class TestFileBackend:
assert cb.version_token(version) == version[0] / 1_000_000_000 > 0
# An opaque non-file version still yields a stable non-zero number.
assert cb.version_token(("etag-1",)) == cb.version_token(("etag-1",)) != 0
+ assert cb.version_token(("etag-1",)) != cb.version_token(("etag-2",))
def test_read_version_and_bom(self, home):
path = home / "config.yaml"
From 4b25b27c8fd294db05bf6c4d923378ce706c61ac Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 15:32:39 -0700
Subject: [PATCH 039/114] fix(config): route the config.yaml readers main added
since the last rebase
stamp_missing_config, the left-core toolset conversion and the auto-prune
answer now go through the config backend seam; setup_agent_import's
before/after byte snapshot of a child import run is marked as a local-file
change check.
---
hermes_cli/config_version_stamp.py | 3 ++-
hermes_cli/left_core_migration.py | 3 ++-
hermes_cli/prune_advisor.py | 4 ++--
hermes_cli/setup_agent_import.py | 2 +-
4 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/hermes_cli/config_version_stamp.py b/hermes_cli/config_version_stamp.py
index df82d83412..f33fde044f 100644
--- a/hermes_cli/config_version_stamp.py
+++ b/hermes_cli/config_version_stamp.py
@@ -100,8 +100,9 @@ def stamp_missing_config() -> bool:
first stamp (:func:`note_first_stamp`) now, before callers trust that shortcut, or the browser those
settings chose is lost. A home without them stays without a config.yaml. Returns whether one was written."""
from hermes_cli.config import _secure_file, atomic_config_write, get_config_path, is_managed
+ from hermes_cli.config_backend import config_exists
path = get_config_path()
- if path.exists() or is_managed():
+ if config_exists(path) or is_managed():
return False
config = {"_config_version": _coerce_config_version(DEFAULT_CONFIG.get("_config_version", 1)) or 1}
if not note_first_stamp(path.parent, {}, config):
diff --git a/hermes_cli/left_core_migration.py b/hermes_cli/left_core_migration.py
index df5b886c4a..096dbcdc3c 100644
--- a/hermes_cli/left_core_migration.py
+++ b/hermes_cli/left_core_migration.py
@@ -521,9 +521,10 @@ def _record_migration(home: Path, feature: LeftCoreFeature) -> None:
under ``_left_core_scoped`` so a later run never undoes a choice the user made since
(``hermes tools``). Raises when config.yaml cannot be read or written."""
from hermes_cli.config import atomic_config_write, read_user_config_raw
+ from hermes_cli.config_backend import config_exists
from hermes_cli.toolset_validation import parse_platform_toolsets_value
path = home / "config.yaml"
- created = not path.exists()
+ created = not config_exists(path)
config = read_user_config_raw(path)
installed = plugin_present(feature.plugin, home) and not _marked(config, _INSTALLED_KEY, feature)
if _scope_kept(config, feature) and not installed:
diff --git a/hermes_cli/prune_advisor.py b/hermes_cli/prune_advisor.py
index 78ebfc0261..0433966dc6 100644
--- a/hermes_cli/prune_advisor.py
+++ b/hermes_cli/prune_advisor.py
@@ -346,14 +346,14 @@ def decide(enable: bool, home: Optional[Path] = None) -> bool:
"""Persist the user's answer as an explicit ``sessions.auto_prune``; ends the prompt for good.
False when the save failed or did not take effect (an administrator-managed value wins)."""
import hermes_yaml as yaml
+ from hermes_cli.config_backend import write_config_key
from hermes_constants import mkdir_under_hermes_home
- from utils import atomic_roundtrip_yaml_update
if managed():
return False
path = _home(home) / "config.yaml"
try:
mkdir_under_hermes_home(path.parent)
- atomic_roundtrip_yaml_update(path, "sessions.auto_prune", bool(enable))
+ write_config_key(path, "sessions.auto_prune", bool(enable))
except (OSError, ValueError, yaml.YAMLError) as exc:
logger.warning("Could not save sessions.auto_prune: %s", exc)
return False
diff --git a/hermes_cli/setup_agent_import.py b/hermes_cli/setup_agent_import.py
index 55006eecea..a431cce0ae 100644
--- a/hermes_cli/setup_agent_import.py
+++ b/hermes_cli/setup_agent_import.py
@@ -119,7 +119,7 @@ def _ensure_plugin(hermes_home: Path) -> bool:
def _snapshot(hermes_home: Path) -> tuple[Optional[bytes], ...]:
- return tuple(p.read_bytes() if p.is_file() else None
+ return tuple(p.read_bytes() if p.is_file() else None # config-reader: ok — child-run change snapshot
for p in (hermes_home / "config.yaml", hermes_home / ".env"))
From bbe207c5935e4df44a950f52d3cc034da831d97a Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 15:35:05 -0700
Subject: [PATCH 040/114] refactor(config): ship the seam with the file backend
only
The seam had a selector and reserved members that nothing in this PR
uses: HERMES_CONFIG_BACKEND accepted only `file` (every other value
exited), the .env override existed only to decide when to refuse,
UserLayer.locks/provenance and ConfigBackend.locked() were always empty,
and the supports_file_tooling()/require_file_tooling() checks at ten
file-copy sites were always true. All of it is for the remote backend
(#122509), so it lands there with the code that makes it real.
get_config_backend() now returns the file backend. The env var, its
docs row, the dotenv override, ConfigBackendUnavailable and the
file-tooling gates are gone, with the tests that covered them. The
explicit-file routing test now proves a non-config.yaml path never
consults the backend.
probe_config_readable() calls a probe_readable() protocol method
instead of an isinstance(FileBackend) check, and version_token()
documents how it treats an int-led version.
---
hermes_cli/backup.py | 4 -
hermes_cli/config.py | 13 +--
hermes_cli/config_backend.py | 101 ++++--------------
hermes_cli/config_backups.py | 13 +--
hermes_cli/env_loader.py | 7 --
hermes_cli/profile_distribution.py | 2 -
hermes_cli/profiles.py | 3 +-
hermes_cli/web_routers/analytics.py | 8 +-
hermes_cli/xai_retirement.py | 9 +-
tests/hermes_cli/test_config_backend.py | 62 ++---------
.../docs/developer-guide/config-backend.md | 20 ++--
.../docs/reference/environment-variables.md | 1 -
12 files changed, 51 insertions(+), 192 deletions(-)
diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py
index 3f4d95fce9..d65ef2bdb6 100644
--- a/hermes_cli/backup.py
+++ b/hermes_cli/backup.py
@@ -43,7 +43,6 @@ from hermes_cli.backup_restore import (
_set_config_path_value,
_validate_backup_zip,
)
-from hermes_cli.config_backend import require_file_tooling
logger = logging.getLogger(__name__)
@@ -598,7 +597,6 @@ def run_backup(args) -> bool:
the caller turns False into exit status 1 so a cron/systemd timer never publishes a "successful"
archive that is missing state.db. Hard failures keep raising ``SystemExit``.
"""
- require_file_tooling("`hermes backup`")
hermes_root = get_default_hermes_root()
if not hermes_root.is_dir():
@@ -735,7 +733,6 @@ def _import_member_rel(member: str, prefix: str) -> tuple[str, bool]:
def run_import(args) -> Optional[int]:
"""Restore a Hermes backup; return 1 on damaged archives or incomplete restores."""
- require_file_tooling("`hermes import`")
zip_path = Path(args.zipfile).expanduser().resolve()
if not zip_path.is_file():
@@ -1542,7 +1539,6 @@ def restore_quick_snapshot(
Returns True if at least one file was restored and the listed auth.json
was not refused or skipped.
"""
- require_file_tooling("Snapshot restore")
from gateway.runtime_ownership import OwnershipConflict, exclusive_maintenance
home = hermes_home or get_hermes_home()
try:
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index bbf0f32f66..d47899f732 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -46,7 +46,7 @@ from hermes_constants import get_hermes_home, get_process_hermes_home
from utils import atomic_replace, fast_safe_load, file_signature, mkstemp_beside
from hermes_cli.config_issues import ConfigIssue, _issue
from hermes_cli.config_backend import (
- config_exists, config_version, probe_config_readable, read_config_doc, supports_file_tooling,
+ config_exists, config_version, probe_config_readable, read_config_doc,
write_config_document)
from hermes_cli.config_read_errors import (
_CONFIG_PARSE_FAILURES, _FIX_PERMS, _FIX_YAML, FailedConfigRead, _backups_dir_display,
@@ -1180,12 +1180,9 @@ def _persist_migration(config: dict[str, Any]) -> None:
persist values that DIFFER from the schema default, plus explicit removals/renames of user
data. Every migration step MUST write through here (``save_config`` with default-stripping
ON, no ``merge_existing``) so the invariant cannot regress one migration at a time. A migration
- is Hermes' own write, never a user turning a feature off. A backend without file tooling
- migrates in memory on read and never writes back (D12)."""
+ is Hermes' own write, never a user turning a feature off."""
from hermes_cli.observability.shared_metrics_disabled import hermes_applied_write
- if not supports_file_tooling():
- return
with hermes_applied_write():
save_config(config)
@@ -2919,12 +2916,8 @@ def edit_config():
if is_managed():
managed_error("edit configuration")
return
- if not supports_file_tooling():
- print("`hermes config edit` opens the local config file, and this config backend has none. "
- "Use `hermes config set ` instead.", file=sys.stderr)
- return
config_path = get_config_path()
- if not config_path.exists(): # config-reader: ok — file tooling, gated on supports_file_tooling()
+ if not config_path.exists(): # config-reader: ok — file tooling (edits the local file)
seed_config_file(config_path)
print(f"Created {config_path}")
diff --git a/hermes_cli/config_backend.py b/hermes_cli/config_backend.py
index dfbed76be5..789c16e1a3 100644
--- a/hermes_cli/config_backend.py
+++ b/hermes_cli/config_backend.py
@@ -1,4 +1,4 @@
-"""Where the user config layer lives: the ``ConfigBackend`` seam (config-config design §4.1, D10/D11).
+"""Where the user config layer lives: the ``ConfigBackend`` seam (config-config design §4.1, D10).
Every read and write of a profile's user ``config.yaml`` goes through the backend returned by
:func:`get_config_backend` (``scripts/check_config_yaml_readers.py`` and
@@ -6,7 +6,7 @@ Every read and write of a profile's user ``config.yaml`` goes through the backen
the hermes home (profile): one multiplexed process serves N profiles, and every config cache
already keys on the config path, so a backend keeps per-home state.
-:class:`FileBackend` is the only backend in this build and is today's behaviour byte for byte:
+:class:`FileBackend` is the only backend and is today's behaviour byte for byte:
the version is the ``file_signature`` stat tuple, reads are ``open`` + ``fast_safe_load`` and raise
exactly what those raise (``FileNotFoundError`` for an absent file, ``OSError``, YAML errors), and
writes are the ruamel round-trip writers in ``utils``. Callers keep their own error handling, so a
@@ -23,7 +23,6 @@ from pathlib import Path
from typing import Any, Mapping, Optional, Protocol, Union, runtime_checkable
CONFIG_FILENAME = "config.yaml"
-BACKEND_ENV = "HERMES_CONFIG_BACKEND"
PathLike = Union[str, "os.PathLike[str]"]
@@ -32,9 +31,7 @@ PathLike = Union[str, "os.PathLike[str]"]
class UserLayer:
"""One read of a profile's user layer."""
doc: Any # the parsed root as written (None for an empty document)
- version: tuple[Any, ...] # the cache signature this doc was read at (etag for a remote backend)
- locks: Mapping[str, str] = field(default_factory=dict) # dotted prefix -> level that locks it
- provenance: str = "" # where the doc came from, for diagnostics
+ version: tuple[Any, ...] # the cache signature this doc was read at
@dataclass(frozen=True)
@@ -67,23 +64,12 @@ class ConfigBackend(Protocol):
def exists(self, home: Path) -> bool: ...
+ def probe_readable(self, home: Path) -> None:
+ """Raise ``OSError`` while the user layer cannot be read; no parse."""
+ ...
+
def write_changes(self, home: Path, changes: Changes) -> None: ...
- def locked(self, home: Path, dotted: str) -> Optional[str]:
- """The level that locks ``dotted`` (prefix-aware), or None."""
- ...
-
- def supports_file_tooling(self) -> bool:
- """Whether tools that copy/edit/back up the config FILE (profile clone, backup/restore,
- ``config edit``, last-known-good backups, ``migrate_config`` write-back) may run."""
- ...
-
-
-class ConfigBackendUnavailable(SystemExit):
- """The selected backend cannot serve config. A ``SystemExit`` on purpose: many config readers
- fail open with ``except Exception`` → defaults, and serving defaults instead of the selected
- backend's config is exactly the silent failure this must not become."""
-
class FileBackend:
"""The user layer is ``/config.yaml`` on local disk."""
@@ -125,7 +111,7 @@ class FileBackend:
def read_user_layer(self, home: Path) -> UserLayer:
path = self.config_path(home)
version = self.version_path(path)
- return UserLayer(doc=self.read_path(path), version=version, provenance=str(path))
+ return UserLayer(doc=self.read_path(path), version=version)
def read_user_doc_readonly(self, home: Path) -> Any:
return self.read_path_readonly(self.config_path(home))
@@ -136,54 +122,21 @@ class FileBackend:
def exists(self, home: Path) -> bool:
return self.config_path(home).exists()
+ def probe_readable(self, home: Path) -> None:
+ path = self.config_path(home)
+ with open(path, "rb") as f:
+ f.read(1)
+
def write_changes(self, home: Path, changes: Changes) -> None:
self.write_path(self.config_path(home), changes)
- def locked(self, home: Path, dotted: str) -> Optional[str]:
- return None # a local file has no levels; the managed scope is a separate overlay
-
- def supports_file_tooling(self) -> bool:
- return True
-
_FILE_BACKEND = FileBackend()
-def _dotenv_backend_override() -> Optional[str]:
- """``HERMES_CONFIG_BACKEND`` as the launch home's ``.env`` then the managed ``.env`` set it
- (both load with override), or None. Consulted only on the refusal path."""
- try:
- from agent.secret_scope import load_env_file
- from hermes_cli import managed_scope
- from hermes_constants import get_process_hermes_home
- except ImportError: # a bare tree without the dotenv stack (pm runtime fixtures) has no .env layer
- return None
- value = load_env_file(get_process_hermes_home() / ".env").get(BACKEND_ENV)
- managed_dir = managed_scope.get_managed_dir()
- if managed_dir is not None:
- value = load_env_file(managed_dir / ".env").get(BACKEND_ENV, value)
- return value
-
-
def get_config_backend() -> ConfigBackend:
- """The backend selected by ``HERMES_CONFIG_BACKEND`` (default ``file``).
-
- Read from the environment on every call, before any config read, so no config value can
- select it (D11). Raises :class:`ConfigBackendUnavailable` for a backend this build lacks.
- """
- kind = os.environ.get(BACKEND_ENV, "").strip().lower() or "file"
- if kind != "file":
- # A config read can run before load_hermes_dotenv() (hermes_cli.config reads at import time):
- # refuse only a value the launch home's .env / managed .env do not override, as that load will.
- override = _dotenv_backend_override() # None = no .env sets it; "" = an explicit reset to file
- kind = (kind if override is None else override).strip().lower() or "file"
- if kind == "file":
- return _FILE_BACKEND
- if kind == "remote":
- raise ConfigBackendUnavailable(
- f"{BACKEND_ENV}=remote: the remote config backend is not available in this build of "
- "Hermes. Unset it to use the local config.yaml.")
- raise ConfigBackendUnavailable(f"{BACKEND_ENV}={kind!r} is not a config backend (expected 'file').")
+ """The backend serving every home's user layer: the file backend, the only one there is."""
+ return _FILE_BACKEND
class _ExplicitFile(FileBackend):
@@ -237,7 +190,8 @@ def config_version_or_none(config_path: PathLike) -> Optional[tuple[Any, ...]]:
def version_token(version: Optional[tuple[Any, ...]]) -> float:
"""A number that changes with a backend ``version`` (0 = no config), for clients that only
- compare successive values: the file backend's version leads with ``st_mtime_ns``, kept as seconds."""
+ compare successive values: the file backend's version leads with ``st_mtime_ns``, kept as seconds.
+ Any other int-led version is scaled the same way, which is fine: only changes are compared."""
if not version:
return 0
if isinstance(version[0], int):
@@ -247,13 +201,9 @@ def version_token(version: Optional[tuple[Any, ...]]) -> float:
def probe_config_readable(config_path: PathLike) -> None:
- """Raise ``OSError`` while a local config file cannot be opened; no parse. A non-file user
- layer has no local read to fail."""
+ """Raise ``OSError`` while a config file cannot be read; no parse."""
backend, home = _route(config_path)
- if isinstance(backend, FileBackend):
- path = backend.config_path(home)
- with open(path, "rb") as f:
- f.read(1)
+ backend.probe_readable(home)
def config_exists(config_path: PathLike) -> bool:
@@ -271,16 +221,3 @@ def write_config_key(config_path: PathLike, key_path: str, value: Any) -> None:
"""Set one dotted key (``None`` removes it) — ``atomic_roundtrip_yaml_update`` semantics."""
backend, home = _route(config_path)
backend.write_changes(home, Changes(unset=(key_path,)) if value is None else Changes(set={key_path: value}))
-
-
-def supports_file_tooling() -> bool:
- """Gate for tools that copy, edit or back up the config FILE itself (§4.3, §4.7)."""
- return get_config_backend().supports_file_tooling()
-
-
-def require_file_tooling(what: str) -> None:
- """Refuse *what* (a file-copy tool) with a clear message when the backend has no config file."""
- if not supports_file_tooling():
- raise ConfigBackendUnavailable(
- f"{what} copies the local config.yaml, and the {get_config_backend().name!r} config "
- "backend has none. It is not available with this backend.")
diff --git a/hermes_cli/config_backups.py b/hermes_cli/config_backups.py
index 0fd922aee4..a776f4eb29 100644
--- a/hermes_cli/config_backups.py
+++ b/hermes_cli/config_backups.py
@@ -46,13 +46,9 @@ def backup_config(config_path: Path, reason: str, *, keep: int = DEFAULT_KEEP) -
Skips when the file is missing/empty, or when the newest backup for *reason* already holds
identical bytes. Never raises: a failed backup must not block the write it precedes.
- Skipped entirely for a config backend without file tooling (no local copies, D2).
"""
- from hermes_cli.config_backend import supports_file_tooling
- if not supports_file_tooling():
- return None
try:
- if not config_path.is_file() or config_path.stat().st_size == 0: # config-reader: ok — file tooling, gated above
+ if not config_path.is_file() or config_path.stat().st_size == 0: # config-reader: ok — file tooling (copies the local file)
return None
root = backups_dir(config_path)
root.mkdir(parents=True, exist_ok=True)
@@ -60,12 +56,12 @@ def backup_config(config_path: Path, reason: str, *, keep: int = DEFAULT_KEEP) -
existing = list_config_backups(config_path, reason)
# Not filecmp.cmp: its process-wide cache keys on (size, mtime), so an equal-size edit
# within one mtime tick of an earlier comparison is reported identical and never backed up.
- if existing and config_path.read_bytes() == existing[0].read_bytes(): # config-reader: ok — file tooling, gated above
+ if existing and config_path.read_bytes() == existing[0].read_bytes(): # config-reader: ok — file tooling (copies the local file)
return None
dest = root / f"{config_path.name}.{reason}.{time.strftime('%Y%m%d-%H%M%S')}"
if dest.is_symlink() or dest.exists(): # never write through a planted link
return None
- shutil.copy2(config_path, dest) # config-reader: ok — file tooling, gated above
+ shutil.copy2(config_path, dest) # config-reader: ok — file tooling (copies the local file)
for stale in [dest, *existing][keep:]:
stale.unlink(missing_ok=True)
return dest
@@ -81,9 +77,6 @@ def load_newest_good_backup(config_path: Path) -> Optional[dict]:
tried: a backup that fails to parse means the on-disk copy was damaged after the fact, and
guessing further back would serve a config the user never saw as current.
"""
- from hermes_cli.config_backend import supports_file_tooling
- if not supports_file_tooling():
- return None
newest = list_config_backups(config_path, "good")[:1]
if not newest:
return None
diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py
index e3727e8d95..b8e007bf13 100644
--- a/hermes_cli/env_loader.py
+++ b/hermes_cli/env_loader.py
@@ -581,13 +581,6 @@ def load_hermes_dotenv(
if os.environ.get(name) != value:
os.environ[name] = value
- # The config backend is selected here — after the .env files, before the first config.yaml read
- # (``_apply_external_secret_sources`` reads ``secrets:``) — so an unavailable backend stops the
- # process before any reader could fall back to defaults (config-config design §4.4, D11/D32).
- from hermes_cli.config_backend import get_config_backend
-
- get_config_backend()
-
# External sources are skipped for the updater (dotenv + managed env still load): ``update`` must not
# import optional secret-manager libs (Bitwarden → cryptography → _rust.pyd) into the process replacing
# that env on Windows, and a fresh retry after a deferred dependency install would otherwise make the
diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py
index d488d06fd1..007b2f4196 100644
--- a/hermes_cli/profile_distribution.py
+++ b/hermes_cli/profile_distribution.py
@@ -539,8 +539,6 @@ def _copy_dist_payload(staged: Path, target: Path, manifest: DistributionManifes
A top-level owned directory, and an owned container (``_is_container``), is merged per
authored root. ``cron/jobs.json`` is special: it is one multi-record runtime store, so
shipped definitions merge by job id instead of replacing the file."""
- from hermes_cli.config_backend import require_file_tooling
- require_file_tooling("Profile distribution install")
target.mkdir(parents=True, exist_ok=True)
entries = list(_owned_entries(staged, manifest))
_refuse_symlinked_targets(target, entries)
diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py
index 28af9e57f5..7be9481a41 100644
--- a/hermes_cli/profiles.py
+++ b/hermes_cli/profiles.py
@@ -24,7 +24,7 @@ from hermes_constants import (
LOCAL_RUNTIME_ROOT_DIRS, PROFILE_ID_RE, clear_named_profile_deleted, mark_named_profile_deleted,
named_profile_has_identity, named_profile_is_deleted, named_profile_is_live,
)
-from hermes_cli.config_backend import config_exists, config_version, config_version_or_none, require_file_tooling
+from hermes_cli.config_backend import config_exists, config_version, config_version_or_none
from hermes_cli.profiles_cache import _PROFILE_FILE_CACHE, _cached_profile_read
logger = logging.getLogger(__name__)
@@ -1128,7 +1128,6 @@ def profiles_to_serve(multiplex: bool, *, include_standalone: bool = False,
def _resolve_clone_source(clone_from: Optional[str]) -> Path:
"""Directory to clone from: the named profile, or the active profile when ``None``."""
- require_file_tooling("Profile clone")
if clone_from is None:
from hermes_constants import get_hermes_home
source_dir = get_hermes_home()
diff --git a/hermes_cli/web_routers/analytics.py b/hermes_cli/web_routers/analytics.py
index 253e5bb85c..ae732bcab7 100644
--- a/hermes_cli/web_routers/analytics.py
+++ b/hermes_cli/web_routers/analytics.py
@@ -41,15 +41,11 @@ async def get_config_raw(profile: Optional[str] = None):
process's own profile, which is wrong under the global profile switcher.
"""
def _run():
- from hermes_cli.config_backend import supports_file_tooling
- if not supports_file_tooling():
- # The raw editor shows the file's bytes; a non-file backend has no file.
- raise HTTPException(status_code=409, detail="This config backend has no raw config file")
with _profile_scope(profile):
path = get_config_path()
- if not path.exists(): # config-reader: ok — file tooling (gated above)
+ if not path.exists(): # config-reader: ok — file tooling (the local file itself)
return {"yaml": "", "path": str(path)}
- return {"yaml": path.read_text(encoding="utf-8-sig"), "path": str(path)} # config-reader: ok — file tooling (gated above)
+ return {"yaml": path.read_text(encoding="utf-8-sig"), "path": str(path)} # config-reader: ok — file tooling (the local file itself)
return await asyncio.to_thread(_run)
diff --git a/hermes_cli/xai_retirement.py b/hermes_cli/xai_retirement.py
index 43a2837f64..e7fa6bc058 100644
--- a/hermes_cli/xai_retirement.py
+++ b/hermes_cli/xai_retirement.py
@@ -131,11 +131,8 @@ def apply_migration(
Unless ``backup=False`` a copy goes to ``backups/config/`` (reason ``pre-migrate-xai``).
"""
from ruamel.yaml import YAML # local import — avoid hard dep at module load
- from hermes_cli.config_backend import require_file_tooling
- # A type-literal-preserving rewrite of the file itself; a non-file backend has no file.
- require_file_tooling("The xAI model migration")
config_path = Path(config_path)
- if not config_path.exists(): # config-reader: ok — file tooling, gated on supports_file_tooling()
+ if not config_path.exists(): # config-reader: ok — file tooling (edits the local file)
raise FileNotFoundError(config_path)
unchanged = ApplyResult(file_path=config_path, backup_path=None, issues_resolved=[], config_changed=False)
if not issues:
@@ -145,7 +142,7 @@ def apply_migration(
yaml = YAML(typ="rt")
yaml.width = ROUNDTRIP_YAML_WIDTH
yaml.preserve_quotes = True
- with config_path.open("r", encoding="utf-8-sig") as fh: # config-reader: ok — file tooling (gated above)
+ with config_path.open("r", encoding="utf-8-sig") as fh: # config-reader: ok — file tooling (the local file itself)
doc = yaml.load(fh)
if doc is None:
return unchanged
@@ -178,5 +175,5 @@ def apply_migration(
# 0640 / container installs; a root-run migration must not flip ownership).
buf = io.StringIO()
yaml.dump(doc, buf)
- atomic_write_text(config_path, buf.getvalue(), preserve_mode=True) # config-reader: ok — file tooling (gated above)
+ atomic_write_text(config_path, buf.getvalue(), preserve_mode=True) # config-reader: ok — file tooling (the local file itself)
return ApplyResult(file_path=config_path, backup_path=backup_path, issues_resolved=resolved, config_changed=True)
diff --git a/tests/hermes_cli/test_config_backend.py b/tests/hermes_cli/test_config_backend.py
index 4bded8a61f..22b2e21122 100644
--- a/tests/hermes_cli/test_config_backend.py
+++ b/tests/hermes_cli/test_config_backend.py
@@ -1,4 +1,4 @@
-"""The ConfigBackend seam (config-config design §4.1, D10/D11) and its reader gate."""
+"""The ConfigBackend seam (config-config design §4.1, D10) and its reader gate."""
import sys
from pathlib import Path
@@ -12,46 +12,21 @@ REPO = Path(__file__).resolve().parents[2]
@pytest.fixture
-def home(tmp_path, monkeypatch):
- monkeypatch.delenv(cb.BACKEND_ENV, raising=False)
+def home(tmp_path):
h = tmp_path / "home"
h.mkdir()
return h
-class TestSelection:
+class TestRouting:
- def test_default_is_file(self, home):
- assert cb.get_config_backend().name == "file"
- assert cb.supports_file_tooling() is True
-
- @pytest.mark.parametrize("value", ["remote", "bogus"])
- def test_unavailable_backend_fails_closed(self, home, monkeypatch, value):
- # No config value can select the backend, and an unknown one never falls back to the file.
- monkeypatch.setenv(cb.BACKEND_ENV, value)
- with pytest.raises(cb.ConfigBackendUnavailable):
- cb.get_config_backend()
- with pytest.raises(cb.ConfigBackendUnavailable):
- cb.read_config_doc(home / "config.yaml")
-
- def test_launch_env_file_overrides_a_stale_shell_value_before_dotenv_loads(self, home, monkeypatch):
- # A config read can precede load_hermes_dotenv(); the launch home's .env wins, as that load will.
- monkeypatch.setenv("HERMES_HOME", str(home))
- monkeypatch.setenv(cb.BACKEND_ENV, "remote")
- (home / ".env").write_text(f"{cb.BACKEND_ENV}=file\n", encoding="utf-8")
- assert cb.get_config_backend().name == "file"
-
- def test_empty_launch_env_value_resets_a_stale_shell_value_to_file(self, home, monkeypatch):
- # ``HERMES_CONFIG_BACKEND=`` in the launch .env loads over the shell value as "", i.e. the default.
- monkeypatch.setenv("HERMES_HOME", str(home))
- monkeypatch.setenv(cb.BACKEND_ENV, "remote")
- (home / ".env").write_text(f"{cb.BACKEND_ENV}=\n", encoding="utf-8")
- assert cb.get_config_backend().name == "file"
-
- def test_explicit_non_config_file_ignores_backend(self, home, monkeypatch):
+ def test_explicit_non_config_file_never_reaches_the_backend(self, home, monkeypatch):
+ # Only ``/config.yaml`` is a user layer; any other file is read where it is.
+ def no_backend():
+ raise AssertionError("an explicit file must not consult the backend")
+ monkeypatch.setattr(cb, "get_config_backend", no_backend)
other = home / "import-source.yaml"
other.write_text("a: 1\n", encoding="utf-8")
- monkeypatch.setenv(cb.BACKEND_ENV, "remote")
assert cb.read_config_doc(other) == {"a": 1}
@@ -144,7 +119,6 @@ def recording(monkeypatch, tmp_path):
(home / "config.yaml").write_text(
"# hand-tuned\nmodel:\n default: my-model\nsecrets:\n sources: []\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
- monkeypatch.delenv(cb.BACKEND_ENV, raising=False)
for cache in (config_mod._LOAD_CONFIG_CACHE, config_mod._RAW_CONFIG_CACHE):
cache.clear()
backend = _RecordingBackend()
@@ -206,26 +180,6 @@ class TestPipelinesConsultTheBackend:
assert "# hand-tuned" in text and "other-model" in text and "kawaii" in text
-class TestFileTooling:
-
- def test_file_backend_allows_it(self, home):
- cb.require_file_tooling("x")
-
- def test_backend_without_a_file_refuses_it(self, home, monkeypatch):
- class NoFile(cb.FileBackend):
- name = "nofile"
-
- def supports_file_tooling(self):
- return False
- monkeypatch.setattr(cb, "get_config_backend", NoFile)
- from hermes_cli.config_backups import backup_config
- (home / "config.yaml").write_text("a: 1\n", encoding="utf-8")
- assert backup_config(home / "config.yaml", "test") is None
- with pytest.raises(cb.ConfigBackendUnavailable, match="Profile clone"):
- from hermes_cli.profiles import _resolve_clone_source
- _resolve_clone_source(None)
-
-
class TestReaderGate:
def _guard(self):
diff --git a/website/docs/developer-guide/config-backend.md b/website/docs/developer-guide/config-backend.md
index aa93f55b41..8c3afdf949 100644
--- a/website/docs/developer-guide/config-backend.md
+++ b/website/docs/developer-guide/config-backend.md
@@ -1,15 +1,17 @@
---
sidebar_position: 12
title: "Config Backend Seam"
-description: "Where a profile's user config.yaml is read and written: the ConfigBackend seam, the file and remote backends, and the CI lints that keep every caller on it"
+description: "Where a profile's user config.yaml is read and written: the ConfigBackend seam, its file backend, and the CI lints that keep every caller on it"
---
# Config Backend Seam
Every read, stat, existence check and write of a profile's user `config.yaml` goes through
-`hermes_cli/config_backend.py`. The backend is chosen once per process from
-`HERMES_CONFIG_BACKEND`, never from a config value: `file` (the default) is the only backend in
-this build; any other value stops the process with a clear error instead of falling back to defaults.
+`hermes_cli/config_backend.py`. `get_config_backend()` returns the backend for every home; today
+that is `FileBackend`, the local `config.yaml` with exactly the primitives Hermes used before the
+seam (stat signature, `fast_safe_load`, the ruamel round-trip writers). A backend that does not
+store the layer as a local file implements the same `ConfigBackend` protocol, and every caller
+already reaches it.
## Helpers
@@ -19,10 +21,12 @@ this build; any other value stops the process with a clear error instead of fall
| `read_config_doc_readonly(path)` | Signature-cached read; never mutate the result |
| `config_version(path)` | Cache signature (the file's stat tuple) |
| `config_exists(path)` | Whether the layer exists |
+| `probe_config_readable(path)` | Raise `OSError` while the layer cannot be read (no parse) |
| `write_config_document(path, doc)` / `write_config_key(path, key, value)` | Writes; callers normally use `atomic_config_write` / `atomic_config_replace` |
-| `supports_file_tooling()` / `require_file_tooling(what)` | Gate for tools that copy, edit or back up the file itself (`config edit`, backup/restore, profile clone) |
-Caches keyed on `config_version` miss whenever the backend's layer changes. The managed scope (`/etc/hermes`) stays an overlay on top
+Only `/config.yaml` is a user layer. Any other file passed to these helpers (an import
+source, a fixture) is read and written where it is. Caches keyed on `config_version` miss
+whenever the backend's layer changes. The managed scope (`/etc/hermes`) stays an overlay on top
of whatever user layer the backend returns.
## Lints
@@ -30,5 +34,5 @@ of whatever user layer the backend returns.
- `scripts/check_config_yaml_writers.py` rejects a direct YAML dump of a config path.
- `scripts/check_config_yaml_readers.py` rejects a direct `open` / `read_text` / `stat` /
`exists` / YAML load of a config path. A true false positive (another product's config
- file, or file tooling already gated on `supports_file_tooling()`) carries
- `# config-reader: ok — ` on the line.
+ file, or a tool that copies or edits the local file itself: `config edit`, backups, profile
+ clone) carries `# config-reader: ok — ` on the line.
diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md
index 88a3094b39..dba4330261 100644
--- a/website/docs/reference/environment-variables.md
+++ b/website/docs/reference/environment-variables.md
@@ -125,7 +125,6 @@ Hermes reads environment variables from the process environment and, for user-ma
| `HERMES_LOCAL_STT_COMMAND` | Optional local speech-to-text command template. Supports `{input_path}`, `{output_dir}`, `{language}`, and `{model}` placeholders |
| `HERMES_LOCAL_STT_LANGUAGE` | Default language hint for STT. Used by the `local` (faster-whisper) provider, `HERMES_LOCAL_STT_COMMAND`, the local `whisper` CLI fallback (default: `en`), Groq, and xAI when no per-provider `language` is set in `config.yaml` |
| `HERMES_HOME` | Select the configuration and user-data home. A literal `~` or `$VAR` in the value is expanded (fish does not expand `~` inside `VAR=~/…`), so it never resolves relative to the current directory. Defaults to `~/.hermes` on POSIX and `%LOCALAPPDATA%\hermes` on Windows; the official Docker image uses `/opt/data`. Profile/runtime context can select a more specific home. |
-| `HERMES_CONFIG_BACKEND` | Select where the profile's `config.yaml` user layer is read and written. Only `file` (the default, the local `config.yaml`) exists in this build; any other value stops Hermes at startup with an error instead of falling back to defaults. Read from the launch environment, the launch home's `.env` and the managed `.env`, never from `config.yaml`. See [Config backend](../developer-guide/config-backend.md). |
| `HERMES_DISABLE_WINDOWS_UTF8` | **Windows only.** Set to `1` to disable the UTF-8 stdio shim (`configure_windows_stdio()`) and fall back to the console's locale code page. Useful for bisecting encoding bugs; rarely the right setting in normal operation |
| `HERMES_KANBAN_HOME` | Override the shared Hermes root that anchors the kanban board (db + workspaces + worker logs). Falls back to `get_default_hermes_root()` (the parent of any active profile). Useful for tests and unusual deployments |
| `HERMES_KANBAN_BOARD` | Pin the active kanban board for this process. Takes precedence over `~/.hermes/kanban/current`; the dispatcher injects this into worker subprocess env so workers physically cannot see tasks on other boards. Defaults to `default`. Slug validation: lowercase alphanumerics + hyphens + underscores, 1-64 chars |
From a3625706772877743eecc328407827abd1b2f796 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 16:10:38 -0700
Subject: [PATCH 041/114] test(skills): count raw-config parses at the config
backend's parser
Main's multiprofile raw-config cache tests (932b20df096, 180ab5e20c8)
patched skill_utils.yaml_load; _load_raw_config now parses through the
config backend (utils.fast_safe_load on the open file), so the patch
never fired and the counts read 0. Same assertions, patched where the
parse now happens.
---
tests/agent/test_skill_utils.py | 30 ++++++++++++++++++------------
1 file changed, 18 insertions(+), 12 deletions(-)
diff --git a/tests/agent/test_skill_utils.py b/tests/agent/test_skill_utils.py
index 6cffa328d0..9b25e19807 100644
--- a/tests/agent/test_skill_utils.py
+++ b/tests/agent/test_skill_utils.py
@@ -83,6 +83,15 @@ class TestMultiprofileRawConfigCache:
scopes plus the ambient one; one profile's cache miss must not evict every
other profile's unchanged entry."""
+ @staticmethod
+ def _patch_config_parser(monkeypatch, parse):
+ """Route config.yaml parses through *parse(text)*: the backend parses with
+ ``utils.fast_safe_load`` on the open file."""
+ import utils
+
+ real = utils.fast_safe_load
+ monkeypatch.setattr(utils, "fast_safe_load", lambda stream: parse(stream.read(), real))
+
@staticmethod
def _load_under(home) -> list:
from agent import skill_utils
@@ -104,14 +113,13 @@ class TestMultiprofileRawConfigCache:
for i, home in enumerate(homes):
_write_disabled_config(home, f"skill-{i:02d}")
parse_count = 0
- real_yaml_load = skill_utils.yaml_load
- def counting_yaml_load(text):
+ def counting_parse(text, real):
nonlocal parse_count
parse_count += 1
- return real_yaml_load(text)
+ return real(text)
- monkeypatch.setattr(skill_utils, "yaml_load", counting_yaml_load)
+ self._patch_config_parser(monkeypatch, counting_parse)
skill_utils._raw_config_cache_clear()
for _ in range(3):
@@ -129,14 +137,13 @@ class TestMultiprofileRawConfigCache:
_write_disabled_config(first, "old-disabled")
_write_disabled_config(second, "second-disabled")
parse_count = 0
- real_yaml_load = skill_utils.yaml_load
- def counting_yaml_load(text):
+ def counting_parse(text, real):
nonlocal parse_count
parse_count += 1
- return real_yaml_load(text)
+ return real(text)
- monkeypatch.setattr(skill_utils, "yaml_load", counting_yaml_load)
+ self._patch_config_parser(monkeypatch, counting_parse)
skill_utils._raw_config_cache_clear()
assert self._load_under(first) == ["old-disabled"]
assert self._load_under(second) == ["second-disabled"]
@@ -162,15 +169,14 @@ class TestMultiprofileRawConfigCache:
_write_disabled_config(first, "first-disabled")
_write_disabled_config(second, "second-disabled")
entered, release = Event(), Event()
- real_yaml_load = skill_utils.yaml_load
- def delayed_yaml_load(text):
+ def delayed_parse(text, real):
if "first-disabled" in text:
entered.set()
assert release.wait(5), "first profile was not released"
- return real_yaml_load(text)
+ return real(text)
- monkeypatch.setattr(skill_utils, "yaml_load", delayed_yaml_load)
+ self._patch_config_parser(monkeypatch, delayed_parse)
skill_utils._raw_config_cache_clear()
with ThreadPoolExecutor(max_workers=2) as pool:
first_result = pool.submit(self._load_under, first)
From 749cb638ff43f9ce5333c186a1e4f67d473aa4f2 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 16:37:30 -0700
Subject: [PATCH 042/114] fix(config): an unreadable config never serves the
TUI's cached empty config; review fixes
- tui_gateway _load_cfg_raw: only FileNotFoundError means "no config".
config_version_or_none folded every stat error into None, so after a
cached absent config a transient EACCES matched the cached {} and the
next config.set could save it over the now-existing file. Any other
stat error is a FailedConfigRead again, which _save_cfg refuses.
- ConfigBackend.read_user_doc replaces read_user_layer/UserLayer: no
caller used the version it stat'ed on every uncached read; cached
loaders already ask config_version first.
- Reader lint scopes follow Python: a self. bound in one method is
seen by every method of the class, and a comprehension's target no
longer leaks into the enclosing function. The class rule found
pm/publication.py's compare-and-swap re-read of self.path, now marked
like its twin in __init__.
---
hermes_cli/config_backend.py | 21 +++++----------
pm/publication.py | 2 +-
scripts/check_config_yaml_readers.py | 18 ++++++++++---
tests/hermes_cli/test_config_backend.py | 20 ++++++++++++--
.../hermes_cli/test_profile_listing_cache.py | 4 +--
.../test_raw_config_cache_stat_errors.py | 26 +++++++++++++++++++
tui_gateway/server.py | 7 +++--
7 files changed, 74 insertions(+), 24 deletions(-)
create mode 100644 tests/tui_gateway/test_raw_config_cache_stat_errors.py
diff --git a/hermes_cli/config_backend.py b/hermes_cli/config_backend.py
index 789c16e1a3..b4b9303795 100644
--- a/hermes_cli/config_backend.py
+++ b/hermes_cli/config_backend.py
@@ -27,13 +27,6 @@ CONFIG_FILENAME = "config.yaml"
PathLike = Union[str, "os.PathLike[str]"]
-@dataclass(frozen=True)
-class UserLayer:
- """One read of a profile's user layer."""
- doc: Any # the parsed root as written (None for an empty document)
- version: tuple[Any, ...] # the cache signature this doc was read at
-
-
@dataclass(frozen=True)
class Changes:
"""A write to one profile's user layer.
@@ -52,10 +45,12 @@ class Changes:
class ConfigBackend(Protocol):
name: str
- def read_user_layer(self, home: Path) -> UserLayer: ...
+ def read_user_doc(self, home: Path) -> Any:
+ """The parsed user layer as written (None for an empty document); version checks are separate."""
+ ...
def read_user_doc_readonly(self, home: Path) -> Any:
- """``read_user_layer(home).doc`` re-read only when ``version`` changes; never mutate it."""
+ """``read_user_doc(home)`` re-read only when ``version`` changes; never mutate it."""
...
def version(self, home: Path) -> tuple[Any, ...]:
@@ -108,10 +103,8 @@ class FileBackend:
def config_path(self, home: Path) -> Path:
return Path(home) / CONFIG_FILENAME
- def read_user_layer(self, home: Path) -> UserLayer:
- path = self.config_path(home)
- version = self.version_path(path)
- return UserLayer(doc=self.read_path(path), version=version)
+ def read_user_doc(self, home: Path) -> Any:
+ return self.read_path(self.config_path(home))
def read_user_doc_readonly(self, home: Path) -> Any:
return self.read_path_readonly(self.config_path(home))
@@ -165,7 +158,7 @@ def _route(config_path: PathLike) -> tuple[ConfigBackend, Path]:
def read_config_doc(config_path: PathLike) -> Any:
"""Parsed root of a config file (None when empty); raises like ``open`` + ``fast_safe_load``."""
backend, home = _route(config_path)
- return backend.read_user_layer(home).doc
+ return backend.read_user_doc(home)
def read_config_doc_readonly(config_path: PathLike) -> Any:
diff --git a/pm/publication.py b/pm/publication.py
index 0deff0ccf9..b89618a375 100644
--- a/pm/publication.py
+++ b/pm/publication.py
@@ -94,7 +94,7 @@ class PluginSelection:
enabled=selection["enabled"], disabled=selection["disabled"])
def publish(self, project: Path) -> None:
- if selection_snapshot() != self.configs or read_bytes_or_none(self.path) != self.previous:
+ if selection_snapshot() != self.configs or read_bytes_or_none(self.path) != self.previous: # config-reader: ok — pm publication: byte-level compare-and-swap rewrite of the local file
raise ValueError("plugin configuration changed while preparing publication; retry")
row = {"config": str(self.path),
"previous": base64.b64encode(self.previous).decode() if self.previous is not None else None,
diff --git a/scripts/check_config_yaml_readers.py b/scripts/check_config_yaml_readers.py
index 86038e00ee..9c9b971fb7 100644
--- a/scripts/check_config_yaml_readers.py
+++ b/scripts/check_config_yaml_readers.py
@@ -80,13 +80,18 @@ def _func_name(node: ast.AST) -> str | None:
return None
+_COMPREHENSIONS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
+# Each binds its own names: a comprehension's target never leaks into the enclosing function.
+_SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef, *_COMPREHENSIONS)
+
+
def _scope_nodes(scope: ast.AST):
- """Nodes of *scope* without descending into nested function/class bodies."""
+ """Nodes of *scope* without descending into nested function/class/comprehension scopes."""
stack = list(ast.iter_child_nodes(scope))
while stack:
node = stack.pop()
yield node
- if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
+ if not isinstance(node, _SCOPES):
stack.extend(ast.iter_child_nodes(node))
@@ -147,6 +152,11 @@ class _Scanner:
if len(names) == before:
return names
+ def class_attrs(self, cls: ast.ClassDef, inherited: set[str]) -> set[str]:
+ """``self.`` bound to a config path in any method: every method of the class sees it."""
+ methods = (n for n in cls.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)))
+ return {name for m in methods for name in self.config_names(m, inherited) if name.startswith("self.")}
+
def _bind(self, target: ast.AST, value: ast.AST, names: set[str]) -> None:
"""Add *target* (a name, ``self.attr``, or an unpacked tuple element) when *value* is a config path."""
if isinstance(target, (ast.Tuple, ast.List)):
@@ -201,10 +211,12 @@ class _Scanner:
def walk(scope: ast.AST, inherited: set[str]) -> None:
names = self.config_names(scope, inherited)
+ if isinstance(scope, ast.ClassDef):
+ names |= self.class_attrs(scope, names)
for node in _scope_nodes(scope):
if isinstance(node, ast.Call):
self.check_call(node, names)
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
+ if isinstance(node, _SCOPES):
walk(node, names)
walk(tree, set())
diff --git a/tests/hermes_cli/test_config_backend.py b/tests/hermes_cli/test_config_backend.py
index 22b2e21122..95cd403a54 100644
--- a/tests/hermes_cli/test_config_backend.py
+++ b/tests/hermes_cli/test_config_backend.py
@@ -86,9 +86,9 @@ class _RecordingBackend(cb.FileBackend):
def _rec(self, op, home):
self.calls.append((op, Path(home)))
- def read_user_layer(self, home):
+ def read_user_doc(self, home):
self._rec("read", home)
- return super().read_user_layer(home)
+ return super().read_user_doc(home)
def read_user_doc_readonly(self, home):
self._rec("read", home)
@@ -241,6 +241,22 @@ class TestReaderGate:
with patch.object(guard, "ROOT", tmp_path):
assert len(guard.scan_file(bad)) == 1
+ def test_scopes_follow_python(self, tmp_path):
+ # An attribute bound in __init__ is seen by every method; a comprehension's name stays inside it.
+ guard = self._guard()
+ src = tmp_path / "hermes_cli" / "scoped.py"
+ src.parent.mkdir()
+ src.write_text(
+ "from hermes_cli.config_backend import read_config_doc\n"
+ "class A:\n def __init__(self, home):\n self.cfg = home / 'config.yaml'\n"
+ " def read(self):\n return self.cfg.read_text()\n"
+ "def b(home, p):\n docs = [read_config_doc(p) for p in (home / 'config.yaml',)]\n"
+ " return docs, p.read_text()\n",
+ encoding="utf-8")
+ with patch.object(guard, "ROOT", tmp_path):
+ problems = guard.scan_file(src)
+ assert [int(p.split(":")[1]) for p in problems] == [6], problems
+
def test_backend_calls_are_clean(self, tmp_path):
guard = self._guard()
good = tmp_path / "hermes_cli" / "good_reader.py"
diff --git a/tests/hermes_cli/test_profile_listing_cache.py b/tests/hermes_cli/test_profile_listing_cache.py
index e527698732..08f18870f1 100644
--- a/tests/hermes_cli/test_profile_listing_cache.py
+++ b/tests/hermes_cli/test_profile_listing_cache.py
@@ -93,7 +93,7 @@ def test_config_model_cache_is_keyed_on_the_backend_version_not_the_local_file(p
assert profiles._read_config_model(profile_dir) == ("gpt-4o", "openai")
version = [("v2",)]
monkeypatch.setattr(config_backend, "config_version", lambda path: version[0])
- monkeypatch.setattr(config_backend.FileBackend, "read_user_layer", lambda self, home: config_backend.UserLayer(
- doc={"model": {"provider": "anthropic", "default": "claude-opus-4.6"}}, version=version[0]))
+ monkeypatch.setattr(config_backend.FileBackend, "read_user_doc",
+ lambda self, home: {"model": {"provider": "anthropic", "default": "claude-opus-4.6"}})
assert profiles._read_config_model(profile_dir) == ("claude-opus-4.6", "anthropic")
diff --git a/tests/tui_gateway/test_raw_config_cache_stat_errors.py b/tests/tui_gateway/test_raw_config_cache_stat_errors.py
new file mode 100644
index 0000000000..2a2598a9a9
--- /dev/null
+++ b/tests/tui_gateway/test_raw_config_cache_stat_errors.py
@@ -0,0 +1,26 @@
+"""The TUI raw-config cache never treats an unreadable config.yaml as an absent one.
+
+``_load_cfg_raw`` feeds ``config.set`` read-modify-write round-trips; serving its cached ``{}`` for a
+config that now exists but cannot be stat'ed would let the next save erase every other setting.
+"""
+
+from hermes_cli import config_backend
+from hermes_cli.config_read_errors import FailedConfigRead
+from tui_gateway import server
+
+
+def test_stat_error_after_a_cached_absent_config_is_a_failed_read(monkeypatch, tmp_path):
+ cfg = tmp_path / "config.yaml"
+ monkeypatch.setattr(server, "_active_config_path", lambda: cfg)
+ monkeypatch.setattr(server, "_cfg_cache", None)
+ monkeypatch.setattr(server, "_cfg_sig", None)
+ monkeypatch.setattr(server, "_cfg_path", None)
+ assert server._load_cfg_raw() == {} # absent: cached as "no config"
+
+ cfg.write_text("display:\n skin: mono\nmodel:\n default: keep-me\n", encoding="utf-8")
+
+ def denied(path):
+ raise PermissionError(13, "Permission denied", str(path))
+
+ monkeypatch.setattr(config_backend, "config_version", denied)
+ assert isinstance(server._load_cfg_raw(), FailedConfigRead)
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index cf1aa513c0..096e6002fc 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -998,12 +998,15 @@ def _load_cfg_raw() -> dict:
expansion applied here would be persisted on the next save). Behavioral reads use :func:`_load_cfg`.
Cache keyed on the resolved path so profiles don't clobber."""
global _cfg_cache, _cfg_sig, _cfg_path
- from hermes_cli.config_backend import config_exists, config_version_or_none
+ from hermes_cli.config_backend import config_exists, config_version
from hermes_cli.config import read_user_config_raw
from hermes_cli.config_read_errors import FailedConfigRead
try:
p = _active_config_path()
- sig = config_version_or_none(p)
+ try:
+ sig = config_version(p)
+ except FileNotFoundError: # absent; any other stat error is a failed read, never "no config"
+ sig = None
with _cfg_lock:
if _cfg_cache is not None and _cfg_sig == sig and _cfg_path == p:
return copy.deepcopy(_cfg_cache)
From 2d6edb59ba46a0b05886511ad8224984ab1d0f75 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 02:46:03 +0000
Subject: [PATCH 043/114] fix(webapp): keep in-page anchors working in HTML
opened in the browser
A srcdoc document takes its parent's base URL, here the wrapper's blob: URL, so href="#sec" resolved to blob:...#sec and the opaque-origin frame could not navigate. A page without its own now gets , inserted after any leading doctype so the page keeps standards mode. The sandbox and attribute escaping are unchanged.
---
.../src/lib/browser-desktop-bridge.test.ts | 43 ++++++++++++++++++-
apps/desktop/src/lib/browser-uploads.ts | 20 ++++++++-
2 files changed, 59 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/lib/browser-desktop-bridge.test.ts b/apps/desktop/src/lib/browser-desktop-bridge.test.ts
index 914ec1b911..ecd811910a 100644
--- a/apps/desktop/src/lib/browser-desktop-bridge.test.ts
+++ b/apps/desktop/src/lib/browser-desktop-bridge.test.ts
@@ -799,7 +799,46 @@ describe('browser-hosted Desktop bridge', () => {
expect(wrapper.querySelectorAll('iframe, script')).toHaveLength(1)
expect(frame.getAttribute('sandbox')).toBe('allow-scripts')
expect(frame.hasAttribute('src')).toBe(false)
- expect(frame.getAttribute('srcdoc')).toBe(page)
+ expect(frame.getAttribute('srcdoc')).toBe(
+ page.replace('', '')
+ )
+ })
+
+ it('gives an HTML page without its own an about:srcdoc base so #anchor links stay in the page', async () => {
+ const win = mutableWindow()
+ seedWebappSession('served-token')
+ const blobs: Blob[] = []
+
+ vi.spyOn(URL, 'createObjectURL').mockImplementation(blob => {
+ blobs.push(blob as Blob)
+
+ return `blob:http://127.0.0.1:9119/page-${blobs.length}`
+ })
+
+ expect(installBrowserDesktopBridge()).toBe(true)
+
+ const srcdocOf = async (page: string) => {
+ await win.hermesDesktop!.saveImageBuffer(new TextEncoder().encode(page), '.html')
+ const wrapper = new DOMParser().parseFromString(await blobs.at(-1)!.text(), 'text/html')
+
+ return wrapper.querySelector('iframe')!.getAttribute('srcdoc')!
+ }
+
+ // Without a base of its own, the page inherits the wrapper's blob: URL and href="#sec" goes nowhere.
+ const bare = '
'
+ expect(await srcdocOf(bare)).toBe(`${bare}`)
+
+ // The base goes after the doctype, so the page is not pushed into quirks mode.
+ const standards = await srcdocOf(`${bare}`)
+ expect(standards).toBe(`${bare}`)
+ const parsed = new DOMParser().parseFromString(standards, 'text/html')
+ expect(parsed.compatMode).toBe('CSS1Compat')
+ expect(parsed.querySelectorAll('base')).toHaveLength(1)
+ expect(parsed.querySelector('base')!.getAttribute('href')).toBe('about:srcdoc')
+
+ // A page that sets its own base keeps it, untouched.
+ const based = 'x'
+ expect(await srcdocOf(based)).toBe(based)
})
it('stages non-image buffers as browser-local object URLs', async () => {
@@ -826,7 +865,7 @@ describe('browser-hosted Desktop bridge', () => {
expect(wrapper).toContain('sandbox="allow-scripts"')
expect(wrapper).not.toContain('allow-same-origin')
expect(wrapper).not.toContain('
preview
')
- expect(wrapper).toContain('srcdoc="<h1>preview</h1>"')
+ expect(wrapper).toContain('srcdoc="<base href="about:srcdoc"><h1>preview</h1>"')
expect(wrapper).not.toMatch(/\ssrc="data:/)
await win.hermesDesktop!.openPreviewInBrowser!(staged)
diff --git a/apps/desktop/src/lib/browser-uploads.ts b/apps/desktop/src/lib/browser-uploads.ts
index d42f420c72..458fdcce1b 100644
--- a/apps/desktop/src/lib/browser-uploads.ts
+++ b/apps/desktop/src/lib/browser-uploads.ts
@@ -50,9 +50,25 @@ function rememberBounded(cache: Map, key: K, value: V): void {
// navigate a frame to a URL over 2 MiB, so a base64 data URL left any page above ~1.5 MB blank.
// Decoded as the data URL's charset=utf-8 did, and escaped for a double-quoted attribute (`&`
// first) so no byte of the page parses as markup of this same-origin wrapper.
+// A srcdoc document inherits the wrapper's blob: base URL, so `#anchor` links would resolve to
+// blob:…#anchor and do nothing. A page without its own gets about:srcdoc, placed after
+// any doctype so the page keeps its rendering mode.
+const SRCDOC_BASE = ''
+const LEADING_DOCTYPE = /^\s*]*>/i
+const BASE_ELEMENT = /]/i
+
+function withSrcdocBase(page: string): string {
+ if (BASE_ELEMENT.test(page)) {
+ return page
+ }
+
+ const doctype = LEADING_DOCTYPE.exec(page)?.[0] ?? ''
+
+ return doctype + SRCDOC_BASE + page.slice(doctype.length)
+}
+
function sandboxedHtmlBlob(bytes: Uint8Array): Blob {
- const srcdoc = new TextDecoder('utf-8')
- .decode(bytes)
+ const srcdoc = withSrcdocBase(new TextDecoder('utf-8').decode(bytes))
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/
Date: Mon, 26 Oct 2026 02:59:38 +0000
Subject: [PATCH 044/114] fix(webapp): put the srcdoc base inside the page's
own head so it keeps its attributes
A inserted before opened an implicit head, so the parser ignored the authored start tag and dropped its attributes (a page reaching document.getElementById on its head threw). The base now goes right after the page's start tag, else its tag, else after a leading doctype.
---
.../src/lib/browser-desktop-bridge.test.ts | 49 +++++++++++++++++++
apps/desktop/src/lib/browser-uploads.ts | 14 ++++--
2 files changed, 59 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/lib/browser-desktop-bridge.test.ts b/apps/desktop/src/lib/browser-desktop-bridge.test.ts
index ecd811910a..850d796d70 100644
--- a/apps/desktop/src/lib/browser-desktop-bridge.test.ts
+++ b/apps/desktop/src/lib/browser-desktop-bridge.test.ts
@@ -841,6 +841,55 @@ describe('browser-hosted Desktop bridge', () => {
expect(await srcdocOf(based)).toBe(based)
})
+ it('puts the about:srcdoc base inside the page\'s own , so the head keeps its attributes', async () => {
+ const win = mutableWindow()
+ seedWebappSession('served-token')
+ const blobs: Blob[] = []
+
+ vi.spyOn(URL, 'createObjectURL').mockImplementation(blob => {
+ blobs.push(blob as Blob)
+
+ return `blob:http://127.0.0.1:9119/page-${blobs.length}`
+ })
+
+ expect(installBrowserDesktopBridge()).toBe(true)
+
+ const parsedSrcdocOf = async (page: string) => {
+ await win.hermesDesktop!.saveImageBuffer(new TextEncoder().encode(page), '.html')
+ const wrapper = new DOMParser().parseFromString(await blobs.at(-1)!.text(), 'text/html')
+ const srcdoc = wrapper.querySelector('iframe')!.getAttribute('srcdoc')!
+
+ return { doc: new DOMParser().parseFromString(srcdoc, 'text/html'), srcdoc }
+ }
+
+ // A base before would open an implicit head, and the authored tag's attributes would be lost.
+ const report = 'rx'
+ const withHead = await parsedSrcdocOf(report)
+ expect(withHead.srcdoc).toBe(report.replace('data-x="1">', 'data-x="1">'))
+ expect(withHead.doc.compatMode).toBe('CSS1Compat')
+ expect(withHead.doc.head.id).toBe('report-assets')
+ expect(withHead.doc.head.dataset.x).toBe('1')
+ expect(withHead.doc.querySelectorAll('base')).toHaveLength(1)
+ expect(withHead.doc.querySelector('base')!.parentElement).toBe(withHead.doc.head)
+ expect(withHead.doc.querySelector('base')!.getAttribute('href')).toBe('about:srcdoc')
+
+ // Without a , the base goes right after , which keeps its attributes too.
+ const headless = '
x
'
+ const withHtml = await parsedSrcdocOf(headless)
+ expect(withHtml.srcdoc).toBe(headless.replace('data-theme="dark">', 'data-theme="dark">'))
+ expect(withHtml.doc.documentElement.lang).toBe('fr')
+ expect(withHtml.doc.documentElement.dataset.theme).toBe('dark')
+ expect(withHtml.doc.querySelectorAll('base')).toHaveLength(1)
+ expect(withHtml.doc.querySelector('base')!.parentElement).toBe(withHtml.doc.head)
+
+ // is not a : the base stays after the doctype.
+ const banner = '
t
x
'
+ const withHeader = await parsedSrcdocOf(banner)
+ expect(withHeader.srcdoc).toBe(banner.replace('', ''))
+ expect(withHeader.doc.querySelectorAll('base')).toHaveLength(1)
+ expect(withHeader.doc.querySelector('header')!.id).toBe('top')
+ })
+
it('stages non-image buffers as browser-local object URLs', async () => {
const win = mutableWindow()
seedWebappSession('served-token')
diff --git a/apps/desktop/src/lib/browser-uploads.ts b/apps/desktop/src/lib/browser-uploads.ts
index 458fdcce1b..e6325db8c9 100644
--- a/apps/desktop/src/lib/browser-uploads.ts
+++ b/apps/desktop/src/lib/browser-uploads.ts
@@ -51,20 +51,26 @@ function rememberBounded(cache: Map, key: K, value: V): void {
// Decoded as the data URL's charset=utf-8 did, and escaped for a double-quoted attribute (`&`
// first) so no byte of the page parses as markup of this same-origin wrapper.
// A srcdoc document inherits the wrapper's blob: base URL, so `#anchor` links would resolve to
-// blob:…#anchor and do nothing. A page without its own gets about:srcdoc, placed after
-// any doctype so the page keeps its rendering mode.
+// blob:…#anchor and do nothing. A page without its own gets about:srcdoc, placed inside
+// the page's own start tag (else its tag, else after any doctype): a base before
+// opens an implicit head, and the parser then drops the authored tag and its attributes.
+// After the doctype, the page keeps its rendering mode.
const SRCDOC_BASE = ''
const LEADING_DOCTYPE = /^\s*]*>/i
const BASE_ELEMENT = /]/i
+const START_TAG_REST = String.raw`(?=[\s/>])(?:"[^"]*"|'[^']*'|[^'">])*>`
+const HEAD_START = new RegExp(`
Date: Sun, 25 Oct 2026 18:03:09 -0700
Subject: [PATCH 045/114] fix(scratch): keep owning a registered gateway across
a wall-clock step
The spawn ledger records psutil's epoch create_time, which on Linux is the
kernel's start ticks plus the /proc/stat btime psutil re-reads on every call.
An NTP or VM-resume step of more than 2 s therefore made a live gateway fail
its own ledger match: the scratch prune disowned it and killed the shell it
ran inside an idle scratch entry, then deleted the entry.
Ledger entries now also carry boot_start / spawner_boot (seconds since boot,
Linux only) and every ledger comparison uses them exactly when present. Older
epoch-only entries keep the 2 s match, except that readers protecting a
process (ledger pruning, the scratch prune's owner check, the data-cleanup
holder refusal) treat a live pid's epoch mismatch on Linux as unprovable
instead of dead, and spawner_is_dead never calls an epoch-only spawner dead
under a boot-stamped child. gateway.status's /proc start-ticks parse reads
bytes after comm's last ")" so a comm with spaces or non-UTF-8 bytes no
longer falls back to the clock-dependent psutil epoch.
---
gateway/status.py | 4 +-
hermes_cli/data_cleanup_holders.py | 4 +-
hermes_cli/process_identity.py | 101 +++++++++++++++++---
hermes_constants_scratch.py | 10 +-
tests/test_scratch_registered_clock_step.py | 52 ++++++++++
5 files changed, 149 insertions(+), 22 deletions(-)
create mode 100644 tests/test_scratch_registered_clock_step.py
diff --git a/gateway/status.py b/gateway/status.py
index 455da6f509..ab16069501 100644
--- a/gateway/status.py
+++ b/gateway/status.py
@@ -489,8 +489,8 @@ def _get_process_start_time(pid: int) -> Optional[int]:
"""
stat_path = Path(f"/proc/{pid}/stat")
try:
- # Field 22 in /proc//stat is process start time (clock ticks).
- return int(stat_path.read_text(encoding="utf-8").split()[21]) # windows-footgun: ok (/proc is BOM-free)
+ # Field 22 (ticks) after comm's last ")": comm may hold spaces or non-UTF-8 bytes.
+ return int(stat_path.read_bytes().rsplit(b")", 1)[1].split()[19])
except (FileNotFoundError, IndexError, PermissionError, ValueError, OSError):
pass
diff --git a/hermes_cli/data_cleanup_holders.py b/hermes_cli/data_cleanup_holders.py
index 48dbe2590b..7c02006133 100644
--- a/hermes_cli/data_cleanup_holders.py
+++ b/hermes_cli/data_cleanup_holders.py
@@ -71,7 +71,7 @@ def _drain_manual_gateway(home: Path) -> None:
def _refuse_backend_writers(home: Path) -> None:
from hermes_constants import get_default_hermes_root
- from hermes_cli.process_identity import LEDGER_FILENAME, _pid_alive_matches, _read_ledger
+ from hermes_cli.process_identity import LEDGER_FILENAME, _read_ledger, entry_still_running
root = get_default_hermes_root(home=home).resolve()
rows = _read_ledger(root / LEDGER_FILENAME)
@@ -85,7 +85,7 @@ def _refuse_backend_writers(home: Path) -> None:
pid = row.get("pid")
if type(pid) is not int or pid <= 0:
raise RuntimeError("backend ownership contains an invalid PID")
- if _pid_alive_matches(pid, row.get("create_time")) is not False:
+ if entry_still_running(row, keep_unprovable=True):
raise RuntimeError(f"backend PID {pid} still owns {home}; close its desktop/dashboard before retrying")
diff --git a/hermes_cli/process_identity.py b/hermes_cli/process_identity.py
index d055ce0d46..51eb8c2f85 100644
--- a/hermes_cli/process_identity.py
+++ b/hermes_cli/process_identity.py
@@ -101,6 +101,21 @@ def worker_birth(proc) -> float:
return process_birth(proc)
+def boot_start(proc) -> Optional[float]:
+ """Seconds from boot to the start of the ``psutil.Process`` *proc* on Linux; ``None`` elsewhere.
+
+ The ledger's epoch ``create_time`` is :func:`process_birth`, which on Linux is the kernel's
+ start ticks plus the ``/proc/stat`` btime psutil re-reads on every call, so an NTP or VM-resume
+ clock step moves it by whole seconds and a live process stops matching its entry. Ledger
+ entries carry this alongside it (``boot_start`` / ``spawner_boot``) and compare it exactly.
+ Raises ``psutil.Error`` like ``create_time()``.
+ """
+ import psutil
+ if getattr(psutil, "LINUX", False):
+ return float(proc._proc.create_time(monotonic=True))
+ return None
+
+
def _process_create_time(pid: Optional[int] = None) -> Optional[float]:
""":func:`process_birth` of ``pid`` (default: this process); ``None`` when psutil can't say."""
try:
@@ -111,6 +126,30 @@ def _process_create_time(pid: Optional[int] = None) -> Optional[float]:
return None
+def _verified_boot_start(pid: Optional[int], create_time: Optional[float]) -> Optional[float]:
+ """:func:`boot_start` of ``pid`` while it still matches ``create_time``; ``None`` when unproven."""
+ if not isinstance(pid, int) or pid <= 0 or create_time is None:
+ return None
+ try:
+ import psutil
+
+ proc = psutil.Process(pid)
+ return boot_start(proc) if _same_incarnation(proc, create_time) else None
+ except Exception:
+ return None
+
+
+def _step_can_explain_mismatch(pid: int, boot: Optional[float]) -> bool:
+ """A live ``pid`` failed an epoch-only match on Linux: a clock step explains that as well as a
+ reused pid, so the mismatch proves nothing. ``False`` when ``boot`` was recorded (exact)."""
+ try:
+ import psutil
+
+ return boot is None and getattr(psutil, "LINUX", False) and psutil.pid_exists(pid)
+ except Exception:
+ return False
+
+
# Layer 1 — spawn tags
@@ -172,6 +211,10 @@ class LedgerEntry:
# `serve --isolated`: opted out of the host singleton (Desktop's SSH backend for another
# machine). Attach-first readers must never adopt it; argv is truncated, so this is canonical.
isolated: bool = False
+ # Linux boot-relative births (:func:`boot_start`) of the process and its spawner: exact
+ # identity across clock steps. ``None`` elsewhere and in older entries (epoch match only).
+ boot_start: Optional[float] = None
+ spawner_boot: Optional[float] = None
def _ledger_path() -> Path:
@@ -220,12 +263,20 @@ def _read_ledger_or_quarantine(path: Path) -> Optional[list[dict]]:
return entries
-def _same_incarnation(proc, create_time: Optional[float]) -> bool:
- """Does the live ``proc`` match a recorded ``create_time`` (2 s tolerance; ``None`` matches)?"""
+def _same_incarnation(proc, create_time: Optional[float], boot: Optional[float] = None, *, exact: bool = False) -> bool:
+ """Does the live ``proc`` match a recorded birth? A recorded ``boot`` (:func:`boot_start`)
+ compares exactly where the live one is readable; otherwise ``create_time`` (2 s tolerance, or
+ equality when ``exact``; ``None`` matches unless ``exact``)."""
+ if boot is not None and (live_boot := boot_start(proc)) is not None:
+ return live_boot == float(boot)
+ if exact:
+ return create_time is not None and process_birth(proc) == create_time
return create_time is None or abs(process_birth(proc) - float(create_time)) < 2.0
-def _pid_alive_matches(pid: int, create_time: Optional[float], *, strict: bool = False) -> Optional[bool]:
+def _pid_alive_matches(
+ pid: int, create_time: Optional[float], *, strict: bool = False, boot: Optional[float] = None,
+) -> Optional[bool]:
"""True/False when provable; ``None`` when psutil can't say."""
try:
import psutil
@@ -234,12 +285,12 @@ def _pid_alive_matches(pid: int, create_time: Optional[float], *, strict: bool =
try:
proc = psutil.Process(int(pid))
if strict:
- return (create_time is not None and process_birth(proc) == create_time
+ return (_same_incarnation(proc, create_time, boot, exact=True)
and proc.is_running() and proc.status() != psutil.STATUS_ZOMBIE)
# A zombie keeps its create_time until its parent reaps it, but it is already dead: the
# dashboard stop check (``gateway.status._pid_exists``) books it stopped, so the ledger must
# not report it as a live pre-update survivor. Windows has no zombies (and status() is slow there).
- return _same_incarnation(proc, create_time) and (
+ return _same_incarnation(proc, create_time, boot) and (
os.name == "nt" or proc.status() != getattr(psutil, "STATUS_ZOMBIE", "zombie"))
except psutil.NoSuchProcess:
return False
@@ -260,6 +311,7 @@ def register_self(purpose: str, *, project_root: Optional[Path] = None, detail:
tag = parse_spawn_tag(os.environ.get(SPAWN_ENV_VAR))
spawner_pid, spawner_create = (tag.spawner_pid, tag.spawner_create) if tag else _desktop_spawner_identity()
entry = _new_entry(os.getpid(), _process_create_time(), purpose, project_root, spawner_pid, spawner_create)
+ entry.spawner_boot = _verified_boot_start(spawner_pid, spawner_create)
entry.hermes_home = hermes_home_key()
if detail:
try:
@@ -352,7 +404,8 @@ def _new_entry(
spawner_pid: Optional[int], spawner_create: Optional[float],
) -> LedgerEntry:
return LedgerEntry(
- pid, create_time, purpose, install_id(project_root), spawner_pid, spawner_create, time.time(), argv=""
+ pid, create_time, purpose, install_id(project_root), spawner_pid, spawner_create, time.time(), argv="",
+ boot_start=_verified_boot_start(pid, create_time),
)
@@ -372,7 +425,7 @@ def _append_entry(entry: LedgerEntry) -> bool:
e for e in entries
if isinstance(e.get("pid"), int)
and e["pid"] != entry.pid
- and _pid_alive_matches(e["pid"], e.get("create_time")) is not False
+ and entry_still_running(e, keep_unprovable=True)
]
pruned.append(asdict(entry))
try:
@@ -403,6 +456,7 @@ def register_child(pid: int, purpose: str, *, project_root: Optional[Path] = Non
if child_create is None:
return False
entry = _new_entry(pid, child_create, purpose, project_root, os.getpid(), _process_create_time())
+ entry.spawner_boot = _verified_boot_start(os.getpid(), entry.spawner_create)
try:
import psutil
@@ -434,27 +488,46 @@ def ledger_entries(
e for e in entries
if (all_installs or e.get("install") == want_install)
and isinstance(e.get("pid"), int)
- and (_pid_alive_matches(e["pid"], e.get("create_time"), strict=True) is True
- if verified_only else _pid_alive_matches(e["pid"], e.get("create_time")) is not False)
+ and (_pid_alive_matches(e["pid"], e.get("create_time"), strict=True, boot=e.get("boot_start")) is True
+ if verified_only else _pid_alive_matches(e["pid"], e.get("create_time"), boot=e.get("boot_start")) is not False)
]
+def entry_still_running(entry: dict, *, keep_unprovable: bool = False) -> bool:
+ """Is the ledger *entry*'s process still the recorded incarnation (``None`` from psutil
+ counts as running)? ``keep_unprovable``: an older entry with only an epoch birth whose live
+ pid disagrees on Linux also counts, because a clock step explains that as well as pid reuse.
+ Readers that protect a process (ledger pruning, the scratch prune's owner check) pass it;
+ killers never do."""
+ pid, boot = entry.get("pid"), entry.get("boot_start")
+ if not isinstance(pid, int) or pid <= 0:
+ return False
+ if _pid_alive_matches(pid, entry.get("create_time"), boot=boot) is not False:
+ return True
+ return keep_unprovable and _step_can_explain_mismatch(pid, boot)
+
+
def spawner_is_dead(entry: dict) -> Optional[bool]:
"""Is the recorded spawner of this entry provably gone? ``None`` when unrecorded/unprovable."""
spawner_pid = entry.get("spawner_pid")
if not isinstance(spawner_pid, int) or spawner_pid <= 0:
return None
- alive = _pid_alive_matches(spawner_pid, entry.get("spawner_create"))
+ boot = entry.get("spawner_boot")
+ alive = _pid_alive_matches(spawner_pid, entry.get("spawner_create"), boot=boot)
+ # An entry that survives a clock step (``boot_start``) with an epoch-only spawner: a step
+ # would read its live spawner as dead and let a reaper kill the child.
+ if alive is False and entry.get("boot_start") is not None and _step_can_explain_mismatch(spawner_pid, boot):
+ return None
return None if alive is None else not alive
-def _terminate_then_kill(pid: int, create_time: Optional[float]) -> bool:
+def _terminate_then_kill(pid: int, create_time: Optional[float], boot: Optional[float] = None) -> bool:
"""SIGTERM, 2 s grace, then SIGKILL. False when the incarnation moved on or is already gone."""
try:
import psutil
proc = psutil.Process(pid)
- if not _same_incarnation(proc, create_time):
+ if not _same_incarnation(proc, create_time, boot):
return False # PID reused since registration — never signal a stranger
proc.terminate()
try:
@@ -529,7 +602,7 @@ def reap_orphaned_backend_owner(
try:
if kill_fn is not None:
kill_fn(pid)
- elif not _terminate_then_kill(pid, entry.get("create_time")):
+ elif not _terminate_then_kill(pid, entry.get("create_time"), entry.get("boot_start")):
return None
except Exception:
logger.debug("backend owner reap failed for %s", entry, exc_info=True)
@@ -564,7 +637,7 @@ def reap_orphaned_mcp_helpers(*, project_root: Optional[Path] = None, kill_fn=No
import psutil
proc = psutil.Process(pid)
- if not _same_incarnation(proc, entry.get("create_time")):
+ if not _same_incarnation(proc, entry.get("create_time"), entry.get("boot_start")):
continue # PID reused since registration
# Windows: descendants (npx.cmd → node.exe) have no pgid to group-kill and
# reparent with ParentId=null when the direct child exits first (#61059), so
diff --git a/hermes_constants_scratch.py b/hermes_constants_scratch.py
index ea9c11cd88..b8571dc6ae 100644
--- a/hermes_constants_scratch.py
+++ b/hermes_constants_scratch.py
@@ -245,7 +245,7 @@ def _registered_live_pids(home: Path) -> set[int]:
matches is a reused pid, not ownership."""
import psutil
- from hermes_cli.process_identity import spawner_is_dead
+ from hermes_cli.process_identity import entry_still_running, spawner_is_dead
from hermes_constants import get_default_hermes_root
root = get_default_hermes_root(home=home)
@@ -267,10 +267,12 @@ def _registered_live_pids(home: Path) -> set[int]:
if entry.get("purpose") == "mcp-helper" and spawner_is_dead(entry) is True:
continue
try:
- if stamp is None:
+ if stamp_key == "create_time":
+ # Boot-relative when recorded; an older epoch-only entry that a clock step
+ # could have shifted stays owned rather than getting its tree killed.
+ alive = entry_still_running(entry, keep_unprovable=True)
+ elif stamp is None:
alive = psutil.pid_exists(pid)
- elif stamp_key == "create_time":
- alive = abs(psutil.Process(pid).create_time() - float(stamp)) < 2.0
else:
from gateway.status import get_process_start_time
diff --git a/tests/test_scratch_registered_clock_step.py b/tests/test_scratch_registered_clock_step.py
new file mode 100644
index 0000000000..b38374a5c5
--- /dev/null
+++ b/tests/test_scratch_registered_clock_step.py
@@ -0,0 +1,52 @@
+"""The scratch prune's owner check survives a wall-clock step (NTP, VM resume)."""
+
+import os
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+import psutil
+import pytest
+
+from hermes_constants import get_scratch_dir, prune_scratch_dir
+from tests.waiting import wait_until
+
+
+@pytest.mark.platforms("linux") # psutil's create_time adds the live /proc/stat btime only here
+@pytest.mark.parametrize("step", [5.0, -5.0])
+def test_prune_keeps_a_registered_gateways_shell_after_a_clock_step(tmp_path, monkeypatch, step):
+ """A gateway registered in the spawn ledger runs a shell inside an idle scratch entry. The
+ wall clock then steps by ``step`` seconds, which moves psutil's epoch ``create_time`` of every
+ process on Linux. The gateway is still the same process: the prune must keep owning it, never
+ kill its shell or delete the entry the shell works in."""
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ scratch = get_scratch_dir(tmp_path, prune=False)
+ lane = scratch / "gateway-lane"
+ lane.mkdir()
+ shell_pidfile = tmp_path / "shell.pid"
+ repo = str(Path(__file__).resolve().parents[1])
+ gateway = subprocess.Popen(
+ [sys.executable, "-c",
+ f"import sys, subprocess, time; sys.path.insert(0, {repo!r}); "
+ "from hermes_cli.process_identity import register_self; assert register_self('gateway'); "
+ f"shell = subprocess.Popen(['sleep', '60'], cwd={str(lane)!r}); "
+ f"open({str(shell_pidfile)!r}, 'w').write(str(shell.pid)); time.sleep(60)"],
+ stdin=subprocess.DEVNULL, env={**os.environ, "HERMES_HOME": str(tmp_path)})
+ try:
+ shell = int(wait_until(lambda: shell_pidfile.exists() and shell_pidfile.read_text().strip(),
+ timeout=20, what="registered gateway started its shell"))
+ real_btime = psutil.boot_time()
+ monkeypatch.setattr(psutil._pslinux, "boot_time", lambda: real_btime + step)
+ ancient = time.time() - 30 * 3600
+ os.utime(lane, (ancient, ancient))
+
+ prune_scratch_dir(scratch)
+
+ assert psutil.Process(shell).status() != psutil.STATUS_ZOMBIE, "the gateway's shell was killed"
+ assert lane.exists(), "the entry the gateway's shell works in was deleted"
+ finally:
+ for child in psutil.Process(gateway.pid).children(recursive=True):
+ child.kill()
+ gateway.kill()
+ gateway.wait(timeout=10)
From def64c1ab0edea288cc8fb29e9fb335ae981a9a0 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 18:06:53 -0700
Subject: [PATCH 046/114] fix(process-identity): keep verified_only on exact
epoch proof
verified_only is positive identity (update scope home, shared-profile
warning): a clock step only withholds that proof, so it stays the exact
epoch match main pins in test_warning_rejects_reused_or_unverifiable_process_identity.
---
hermes_cli/process_identity.py | 13 ++++++-------
1 file changed, 6 insertions(+), 7 deletions(-)
diff --git a/hermes_cli/process_identity.py b/hermes_cli/process_identity.py
index 51eb8c2f85..3f00931518 100644
--- a/hermes_cli/process_identity.py
+++ b/hermes_cli/process_identity.py
@@ -263,14 +263,12 @@ def _read_ledger_or_quarantine(path: Path) -> Optional[list[dict]]:
return entries
-def _same_incarnation(proc, create_time: Optional[float], boot: Optional[float] = None, *, exact: bool = False) -> bool:
+def _same_incarnation(proc, create_time: Optional[float], boot: Optional[float] = None) -> bool:
"""Does the live ``proc`` match a recorded birth? A recorded ``boot`` (:func:`boot_start`)
- compares exactly where the live one is readable; otherwise ``create_time`` (2 s tolerance, or
- equality when ``exact``; ``None`` matches unless ``exact``)."""
+ compares exactly where the live one is readable; otherwise ``create_time`` (2 s tolerance;
+ ``None`` matches)."""
if boot is not None and (live_boot := boot_start(proc)) is not None:
return live_boot == float(boot)
- if exact:
- return create_time is not None and process_birth(proc) == create_time
return create_time is None or abs(process_birth(proc) - float(create_time)) < 2.0
@@ -284,8 +282,9 @@ def _pid_alive_matches(
return None
try:
proc = psutil.Process(int(pid))
+ # ``strict`` is positive proof (an exact epoch birth); a clock step only withholds it.
if strict:
- return (_same_incarnation(proc, create_time, boot, exact=True)
+ return (create_time is not None and process_birth(proc) == create_time
and proc.is_running() and proc.status() != psutil.STATUS_ZOMBIE)
# A zombie keeps its create_time until its parent reaps it, but it is already dead: the
# dashboard stop check (``gateway.status._pid_exists``) books it stopped, so the ledger must
@@ -488,7 +487,7 @@ def ledger_entries(
e for e in entries
if (all_installs or e.get("install") == want_install)
and isinstance(e.get("pid"), int)
- and (_pid_alive_matches(e["pid"], e.get("create_time"), strict=True, boot=e.get("boot_start")) is True
+ and (_pid_alive_matches(e["pid"], e.get("create_time"), strict=True) is True
if verified_only else _pid_alive_matches(e["pid"], e.get("create_time"), boot=e.get("boot_start")) is not False)
]
From b5b74e4ff254bf5ca5430e7b2b81c49328c9749b Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 18:07:52 -0700
Subject: [PATCH 047/114] refactor(process-identity): narrow the boot-start
probes for the health ratchet
---
hermes_cli/process_identity.py | 9 +++------
1 file changed, 3 insertions(+), 6 deletions(-)
diff --git a/hermes_cli/process_identity.py b/hermes_cli/process_identity.py
index 3f00931518..6a25f62d2a 100644
--- a/hermes_cli/process_identity.py
+++ b/hermes_cli/process_identity.py
@@ -135,19 +135,16 @@ def _verified_boot_start(pid: Optional[int], create_time: Optional[float]) -> Op
proc = psutil.Process(pid)
return boot_start(proc) if _same_incarnation(proc, create_time) else None
- except Exception:
+ except Exception: # health: allow BLE001 -- best-effort probe like _process_create_time: any failure is "unproven"
return None
def _step_can_explain_mismatch(pid: int, boot: Optional[float]) -> bool:
"""A live ``pid`` failed an epoch-only match on Linux: a clock step explains that as well as a
reused pid, so the mismatch proves nothing. ``False`` when ``boot`` was recorded (exact)."""
- try:
- import psutil
+ import psutil # the caller's epoch match already needed it
- return boot is None and getattr(psutil, "LINUX", False) and psutil.pid_exists(pid)
- except Exception:
- return False
+ return boot is None and bool(getattr(psutil, "LINUX", False)) and psutil.pid_exists(pid)
# Layer 1 — spawn tags
From 6c3f8a2542b2751dbe251ed4852ffb874d20ef34 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 18:40:57 -0700
Subject: [PATCH 048/114] fix(process-identity): trust boot ticks only within
the boot that recorded them
Review findings on #144757:
- Stale entries match another boot: boot_start/spawner_boot restart at every
boot while the spawn ledger survives reboots, so a pre-reboot entry whose
pid and start tick equal a process of the new boot passed as that process
and reap_orphaned_mcp_helpers SIGTERMed the stranger. Entries now record
the kernel boot_id next to the ticks (read once per process); the ticks
are exact proof only when it equals the running boot's id, an entry from
another boot matches nothing alive (its spawner reads as dead, as the
epoch match says), and no ticks are written when the boot id is unknown.
- Exited backends block cleanup: for an old epoch-only entry the clock-step
leniency (keep_unprovable) accepted a zombie because psutil.pid_exists
stays true until the parent reaps it, so _refuse_backend_writers refused
cleanup after the backend exited and _append_entry kept the dead row. The
leniency now applies only to a running, non-zombie pid.
---
hermes_cli/process_identity.py | 64 ++++++++++++++++-----
tests/test_scratch_registered_clock_step.py | 49 +++++++++++++++-
2 files changed, 99 insertions(+), 14 deletions(-)
diff --git a/hermes_cli/process_identity.py b/hermes_cli/process_identity.py
index 6a25f62d2a..c9ba595863 100644
--- a/hermes_cli/process_identity.py
+++ b/hermes_cli/process_identity.py
@@ -9,6 +9,7 @@ root — no launcher→worker chains left holding ``.pyd`` locks.
from __future__ import annotations
+import functools
import hashlib
import json
import logging
@@ -126,9 +127,37 @@ def _process_create_time(pid: Optional[int] = None) -> Optional[float]:
return None
+@functools.lru_cache(maxsize=1)
+def _live_boot_id() -> Optional[str]:
+ """The kernel's id for the running boot (Linux); ``None`` elsewhere or when unreadable."""
+ try:
+ return Path("/proc/sys/kernel/random/boot_id").read_text(encoding="utf-8").strip() or None
+ except OSError:
+ return None
+
+
+# No live process has a negative boot_start, so an entry from an earlier boot never matches.
+_EARLIER_BOOT = -1.0
+
+
+def _entry_boot(entry: dict, key: str) -> Optional[float]:
+ """The entry's boot-relative birth ``key`` (``boot_start``/``spawner_boot``) as exact proof.
+
+ Boot ticks restart at every boot while the ledger survives reboots (and early-boot services
+ get the same pid at the same tick), so they identify a process only within the boot that
+ recorded them: an entry from another boot is :data:`_EARLIER_BOOT`, matching nothing alive.
+ ``None`` (epoch match) when either boot id is unknown.
+ """
+ recorded, live = entry.get("boot_id"), _live_boot_id()
+ if not isinstance(recorded, str) or live is None:
+ return None
+ return entry.get(key) if recorded == live else _EARLIER_BOOT
+
+
def _verified_boot_start(pid: Optional[int], create_time: Optional[float]) -> Optional[float]:
- """:func:`boot_start` of ``pid`` while it still matches ``create_time``; ``None`` when unproven."""
- if not isinstance(pid, int) or pid <= 0 or create_time is None:
+ """:func:`boot_start` of ``pid`` while it still matches ``create_time``; ``None`` when unproven
+ or when the boot is unidentifiable (ticks alone repeat across reboots)."""
+ if not isinstance(pid, int) or pid <= 0 or create_time is None or _live_boot_id() is None:
return None
try:
import psutil
@@ -140,11 +169,17 @@ def _verified_boot_start(pid: Optional[int], create_time: Optional[float]) -> Op
def _step_can_explain_mismatch(pid: int, boot: Optional[float]) -> bool:
- """A live ``pid`` failed an epoch-only match on Linux: a clock step explains that as well as a
- reused pid, so the mismatch proves nothing. ``False`` when ``boot`` was recorded (exact)."""
+ """A running ``pid`` failed an epoch-only match on Linux: a clock step explains that as well as
+ a reused pid, so the mismatch proves nothing. ``False`` when ``boot`` gave an exact verdict and
+ when ``pid`` is gone or a zombie (exited, only awaiting its parent's reap)."""
import psutil # the caller's epoch match already needed it
- return boot is None and bool(getattr(psutil, "LINUX", False)) and psutil.pid_exists(pid)
+ if boot is not None or not getattr(psutil, "LINUX", False):
+ return False
+ try:
+ return psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
+ except psutil.Error:
+ return False
# Layer 1 — spawn tags
@@ -209,9 +244,11 @@ class LedgerEntry:
# machine). Attach-first readers must never adopt it; argv is truncated, so this is canonical.
isolated: bool = False
# Linux boot-relative births (:func:`boot_start`) of the process and its spawner: exact
- # identity across clock steps. ``None`` elsewhere and in older entries (epoch match only).
+ # identity across clock steps, within the boot named by ``boot_id`` (:func:`_entry_boot`).
+ # ``None`` elsewhere and in older entries (epoch match only).
boot_start: Optional[float] = None
spawner_boot: Optional[float] = None
+ boot_id: Optional[str] = None
def _ledger_path() -> Path:
@@ -401,7 +438,7 @@ def _new_entry(
) -> LedgerEntry:
return LedgerEntry(
pid, create_time, purpose, install_id(project_root), spawner_pid, spawner_create, time.time(), argv="",
- boot_start=_verified_boot_start(pid, create_time),
+ boot_start=_verified_boot_start(pid, create_time), boot_id=_live_boot_id(),
)
@@ -485,7 +522,7 @@ def ledger_entries(
if (all_installs or e.get("install") == want_install)
and isinstance(e.get("pid"), int)
and (_pid_alive_matches(e["pid"], e.get("create_time"), strict=True) is True
- if verified_only else _pid_alive_matches(e["pid"], e.get("create_time"), boot=e.get("boot_start")) is not False)
+ if verified_only else _pid_alive_matches(e["pid"], e.get("create_time"), boot=_entry_boot(e, "boot_start")) is not False)
]
@@ -495,7 +532,7 @@ def entry_still_running(entry: dict, *, keep_unprovable: bool = False) -> bool:
pid disagrees on Linux also counts, because a clock step explains that as well as pid reuse.
Readers that protect a process (ledger pruning, the scratch prune's owner check) pass it;
killers never do."""
- pid, boot = entry.get("pid"), entry.get("boot_start")
+ pid, boot = entry.get("pid"), _entry_boot(entry, "boot_start")
if not isinstance(pid, int) or pid <= 0:
return False
if _pid_alive_matches(pid, entry.get("create_time"), boot=boot) is not False:
@@ -508,11 +545,12 @@ def spawner_is_dead(entry: dict) -> Optional[bool]:
spawner_pid = entry.get("spawner_pid")
if not isinstance(spawner_pid, int) or spawner_pid <= 0:
return None
- boot = entry.get("spawner_boot")
+ # A spawner recorded in an earlier boot is dead (:func:`_entry_boot`), as the epoch match says.
+ boot = _entry_boot(entry, "spawner_boot")
alive = _pid_alive_matches(spawner_pid, entry.get("spawner_create"), boot=boot)
# An entry that survives a clock step (``boot_start``) with an epoch-only spawner: a step
# would read its live spawner as dead and let a reaper kill the child.
- if alive is False and entry.get("boot_start") is not None and _step_can_explain_mismatch(spawner_pid, boot):
+ if alive is False and _entry_boot(entry, "boot_start") is not None and _step_can_explain_mismatch(spawner_pid, boot):
return None
return None if alive is None else not alive
@@ -598,7 +636,7 @@ def reap_orphaned_backend_owner(
try:
if kill_fn is not None:
kill_fn(pid)
- elif not _terminate_then_kill(pid, entry.get("create_time"), entry.get("boot_start")):
+ elif not _terminate_then_kill(pid, entry.get("create_time"), _entry_boot(entry, "boot_start")):
return None
except Exception:
logger.debug("backend owner reap failed for %s", entry, exc_info=True)
@@ -633,7 +671,7 @@ def reap_orphaned_mcp_helpers(*, project_root: Optional[Path] = None, kill_fn=No
import psutil
proc = psutil.Process(pid)
- if not _same_incarnation(proc, entry.get("create_time"), entry.get("boot_start")):
+ if not _same_incarnation(proc, entry.get("create_time"), _entry_boot(entry, "boot_start")):
continue # PID reused since registration
# Windows: descendants (npx.cmd → node.exe) have no pgid to group-kill and
# reparent with ParentId=null when the direct child exits first (#61059), so
diff --git a/tests/test_scratch_registered_clock_step.py b/tests/test_scratch_registered_clock_step.py
index b38374a5c5..fdf01302bd 100644
--- a/tests/test_scratch_registered_clock_step.py
+++ b/tests/test_scratch_registered_clock_step.py
@@ -1,5 +1,7 @@
-"""The scratch prune's owner check survives a wall-clock step (NTP, VM resume)."""
+"""The spawn ledger's boot-relative identity: a registered gateway's scratch survives a wall-clock
+step (NTP, VM resume), and boot ticks never vouch for a process across a reboot."""
+import json
import os
import subprocess
import sys
@@ -9,6 +11,10 @@ from pathlib import Path
import psutil
import pytest
+from hermes_cli.data_cleanup_holders import _refuse_backend_writers
+from hermes_cli.process_identity import (
+ _ledger_path, boot_start, install_id, ledger_entries, reap_orphaned_mcp_helpers, register_self,
+)
from hermes_constants import get_scratch_dir, prune_scratch_dir
from tests.waiting import wait_until
@@ -50,3 +56,44 @@ def test_prune_keeps_a_registered_gateways_shell_after_a_clock_step(tmp_path, mo
child.kill()
gateway.kill()
gateway.wait(timeout=10)
+
+
+@pytest.mark.platforms("linux") # boot ids and boot-relative ticks are recorded only here
+def test_ledger_never_vouches_for_an_earlier_boot_or_an_exited_backend(tmp_path, monkeypatch):
+ """The ledger survives reboots but boot ticks restart: an MCP-helper entry written in an
+ earlier boot whose pid and start tick equal an unrelated process running now (early-boot
+ services are deterministic) names a stranger, and its spawner is gone, so the orphan reaper
+ must leave the stranger alone. An older epoch-only serve entry whose backend has exited (a
+ zombie its parent has not reaped yet) no longer owns HERMES_HOME. Neither row survives the
+ next ledger write."""
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ stranger = subprocess.Popen(["sleep", "60"], stdin=subprocess.DEVNULL)
+ backend = subprocess.Popen(["true"], stdin=subprocess.DEVNULL)
+ spawner = subprocess.Popen(["true"], stdin=subprocess.DEVNULL)
+ spawner.wait(timeout=10)
+ try:
+ wait_until(lambda: psutil.Process(backend.pid).status() == psutil.STATUS_ZOMBIE,
+ timeout=10, what="the backend exited")
+ live = psutil.Process(stranger.pid)
+ earlier_boot = time.time() - 3 * 86400
+ _ledger_path().write_text(json.dumps([
+ {"pid": stranger.pid, "create_time": live.create_time() - 3 * 86400, "purpose": "mcp-helper",
+ "install": install_id(), "spawner_pid": spawner.pid, "spawner_create": earlier_boot,
+ "registered_at": earlier_boot, "argv": "", "boot_start": boot_start(live), "spawner_boot": 1.0,
+ "boot_id": "00000000-0000-4000-8000-000000000000"},
+ {"pid": backend.pid, "create_time": psutil.Process(backend.pid).create_time(), "purpose": "serve",
+ "install": install_id(), "spawner_pid": None, "spawner_create": None,
+ "registered_at": time.time(), "argv": ""},
+ ]), encoding="utf-8")
+
+ assert stranger.pid not in [e["pid"] for e in ledger_entries()]
+ assert reap_orphaned_mcp_helpers() == []
+ assert stranger.poll() is None, "the orphan reaper killed a process from a later boot"
+ _refuse_backend_writers(tmp_path) # raises while a backend still owns the home
+ assert register_self("gateway")
+ rows = json.loads(_ledger_path().read_text(encoding="utf-8"))
+ assert [row["pid"] for row in rows] == [os.getpid()]
+ finally:
+ stranger.kill()
+ stranger.wait(timeout=10)
+ backend.wait(timeout=10)
From 00540458b77a3ecaeb83b2590537f04384eb25c3 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Sun, 25 Oct 2026 19:05:46 -0700
Subject: [PATCH 049/114] fix(process-identity): old-format ledger entries keep
main's epoch match
Review round 2 on #144757 (finding: old entries protect a reused pid):
entries without boot ticks (written before upgrade, or where boot_id is
unreadable) got clock-step leniency through entry_still_running's
keep_unprovable path, so a serve entry whose pid a live unrelated process
had reused was never pruned by _append_entry, and
data_cleanup_holders._refuse_backend_writers refused cleanup until that
stranger exited; main proceeds and prunes the row.
Clock-step immunity now comes only from boot ticks recorded in the running
boot. Removed keep_unprovable and _step_can_explain_mismatch, including the
spawner_is_dead leniency (an epoch-only spawner whose pid a stranger holds
now reads as dead, as on main, so its orphaned MCP helper is reaped).
Old-format entries keep main's epoch match until their processes restart
and rewrite them.
---
hermes_cli/data_cleanup_holders.py | 2 +-
hermes_cli/process_identity.py | 34 ++++-----------------
hermes_constants_scratch.py | 4 +--
tests/test_scratch_registered_clock_step.py | 10 ++++--
4 files changed, 15 insertions(+), 35 deletions(-)
diff --git a/hermes_cli/data_cleanup_holders.py b/hermes_cli/data_cleanup_holders.py
index 7c02006133..c722c73933 100644
--- a/hermes_cli/data_cleanup_holders.py
+++ b/hermes_cli/data_cleanup_holders.py
@@ -85,7 +85,7 @@ def _refuse_backend_writers(home: Path) -> None:
pid = row.get("pid")
if type(pid) is not int or pid <= 0:
raise RuntimeError("backend ownership contains an invalid PID")
- if entry_still_running(row, keep_unprovable=True):
+ if entry_still_running(row):
raise RuntimeError(f"backend PID {pid} still owns {home}; close its desktop/dashboard before retrying")
diff --git a/hermes_cli/process_identity.py b/hermes_cli/process_identity.py
index c9ba595863..76ba688c73 100644
--- a/hermes_cli/process_identity.py
+++ b/hermes_cli/process_identity.py
@@ -168,20 +168,6 @@ def _verified_boot_start(pid: Optional[int], create_time: Optional[float]) -> Op
return None
-def _step_can_explain_mismatch(pid: int, boot: Optional[float]) -> bool:
- """A running ``pid`` failed an epoch-only match on Linux: a clock step explains that as well as
- a reused pid, so the mismatch proves nothing. ``False`` when ``boot`` gave an exact verdict and
- when ``pid`` is gone or a zombie (exited, only awaiting its parent's reap)."""
- import psutil # the caller's epoch match already needed it
-
- if boot is not None or not getattr(psutil, "LINUX", False):
- return False
- try:
- return psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
- except psutil.Error:
- return False
-
-
# Layer 1 — spawn tags
@@ -458,7 +444,7 @@ def _append_entry(entry: LedgerEntry) -> bool:
e for e in entries
if isinstance(e.get("pid"), int)
and e["pid"] != entry.pid
- and entry_still_running(e, keep_unprovable=True)
+ and entry_still_running(e)
]
pruned.append(asdict(entry))
try:
@@ -526,18 +512,14 @@ def ledger_entries(
]
-def entry_still_running(entry: dict, *, keep_unprovable: bool = False) -> bool:
+def entry_still_running(entry: dict) -> bool:
"""Is the ledger *entry*'s process still the recorded incarnation (``None`` from psutil
- counts as running)? ``keep_unprovable``: an older entry with only an epoch birth whose live
- pid disagrees on Linux also counts, because a clock step explains that as well as pid reuse.
- Readers that protect a process (ledger pruning, the scratch prune's owner check) pass it;
- killers never do."""
- pid, boot = entry.get("pid"), _entry_boot(entry, "boot_start")
+ counts as running)? Exact boot ticks within their boot survive a clock step; an entry
+ without them keeps the 2 s epoch match, so a step reads it as a reused pid."""
+ pid = entry.get("pid")
if not isinstance(pid, int) or pid <= 0:
return False
- if _pid_alive_matches(pid, entry.get("create_time"), boot=boot) is not False:
- return True
- return keep_unprovable and _step_can_explain_mismatch(pid, boot)
+ return _pid_alive_matches(pid, entry.get("create_time"), boot=_entry_boot(entry, "boot_start")) is not False
def spawner_is_dead(entry: dict) -> Optional[bool]:
@@ -548,10 +530,6 @@ def spawner_is_dead(entry: dict) -> Optional[bool]:
# A spawner recorded in an earlier boot is dead (:func:`_entry_boot`), as the epoch match says.
boot = _entry_boot(entry, "spawner_boot")
alive = _pid_alive_matches(spawner_pid, entry.get("spawner_create"), boot=boot)
- # An entry that survives a clock step (``boot_start``) with an epoch-only spawner: a step
- # would read its live spawner as dead and let a reaper kill the child.
- if alive is False and _entry_boot(entry, "boot_start") is not None and _step_can_explain_mismatch(spawner_pid, boot):
- return None
return None if alive is None else not alive
diff --git a/hermes_constants_scratch.py b/hermes_constants_scratch.py
index b8571dc6ae..32d1879f04 100644
--- a/hermes_constants_scratch.py
+++ b/hermes_constants_scratch.py
@@ -268,9 +268,7 @@ def _registered_live_pids(home: Path) -> set[int]:
continue
try:
if stamp_key == "create_time":
- # Boot-relative when recorded; an older epoch-only entry that a clock step
- # could have shifted stays owned rather than getting its tree killed.
- alive = entry_still_running(entry, keep_unprovable=True)
+ alive = entry_still_running(entry) # boot ticks survive a clock step
elif stamp is None:
alive = psutil.pid_exists(pid)
else:
diff --git a/tests/test_scratch_registered_clock_step.py b/tests/test_scratch_registered_clock_step.py
index fdf01302bd..5f46db5a61 100644
--- a/tests/test_scratch_registered_clock_step.py
+++ b/tests/test_scratch_registered_clock_step.py
@@ -63,9 +63,10 @@ def test_ledger_never_vouches_for_an_earlier_boot_or_an_exited_backend(tmp_path,
"""The ledger survives reboots but boot ticks restart: an MCP-helper entry written in an
earlier boot whose pid and start tick equal an unrelated process running now (early-boot
services are deterministic) names a stranger, and its spawner is gone, so the orphan reaper
- must leave the stranger alone. An older epoch-only serve entry whose backend has exited (a
- zombie its parent has not reaped yet) no longer owns HERMES_HOME. Neither row survives the
- next ledger write."""
+ must leave the stranger alone. Older epoch-only serve entries (written before boot ticks) keep
+ main's epoch match: one whose backend has exited (a zombie its parent has not reaped yet) and
+ one whose pid the stranger now holds no longer own HERMES_HOME. No row survives the next
+ ledger write."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
stranger = subprocess.Popen(["sleep", "60"], stdin=subprocess.DEVNULL)
backend = subprocess.Popen(["true"], stdin=subprocess.DEVNULL)
@@ -84,6 +85,9 @@ def test_ledger_never_vouches_for_an_earlier_boot_or_an_exited_backend(tmp_path,
{"pid": backend.pid, "create_time": psutil.Process(backend.pid).create_time(), "purpose": "serve",
"install": install_id(), "spawner_pid": None, "spawner_create": None,
"registered_at": time.time(), "argv": ""},
+ {"pid": stranger.pid, "create_time": live.create_time() - 3 * 86400, "purpose": "serve",
+ "install": install_id(), "spawner_pid": None, "spawner_create": None,
+ "registered_at": earlier_boot, "argv": ""},
]), encoding="utf-8")
assert stranger.pid not in [e["pid"] for e in ledger_entries()]
From 7aeeb3b13f5eec6e4562d34511621d3d33983ca6 Mon Sep 17 00:00:00 2001
From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com>
Date: Thu, 22 Oct 2026 12:23:44 +0300
Subject: [PATCH 050/114] fix(webapp): keep the router's history state when a
profile pick rewrites the URL
A live profile pick in the Webapp records the profile in the tab's
?profile= with history.replaceState(null, ...), which wiped
react-router's HashRouter entry state (idx, key and navigation state) for
the current entry, so later back/forward deltas and that entry's location
state were lost.
profile.remember now passes the current history.state through and only
changes the URL.
Refs #93508 (review P3)
(cherry picked from commit 8c245c6f1133d3c2bf33e46c11472660e4730212)
---
apps/desktop/src/lib/browser-profile.test.ts | 12 ++++++++++++
apps/desktop/src/lib/browser-profile.ts | 3 ++-
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/lib/browser-profile.test.ts b/apps/desktop/src/lib/browser-profile.test.ts
index 3ebe8d21eb..baaa58f817 100644
--- a/apps/desktop/src/lib/browser-profile.test.ts
+++ b/apps/desktop/src/lib/browser-profile.test.ts
@@ -64,6 +64,18 @@ it('persists the default separately from the active chat and synchronizes only t
expect(changed).not.toHaveBeenCalled()
})
+it('records a live profile pick in the URL without dropping the router entry state', async () => {
+ const entry = { idx: 3, key: 'entry', usr: { from: 'sidebar' } }
+ window.history.replaceState(entry, '', '/hermes?profile=active#/session-1')
+ expect(installBrowserDesktopBridge()).toBe(true)
+
+ await window.hermesDesktop.profile.remember('work')
+
+ expect(new URLSearchParams(window.location.search).get('profile')).toBe('work')
+ expect(window.location.hash).toBe('#/session-1')
+ expect(window.history.state).toEqual(entry)
+})
+
it('opens the requested profile on this host without carrying another session or spectator flags', async () => {
// A gated host opens the window directly; a launch-link tab hands it a session first.
bootstrap.__HERMES_AUTH_REQUIRED__ = true
diff --git a/apps/desktop/src/lib/browser-profile.ts b/apps/desktop/src/lib/browser-profile.ts
index 2aead968a6..101a407c04 100644
--- a/apps/desktop/src/lib/browser-profile.ts
+++ b/apps/desktop/src/lib/browser-profile.ts
@@ -60,7 +60,8 @@ export function createBrowserProfileBridge({
const selected = profile?.trim() || null
const url = new URL(window.location.href)
setUrlProfile(url, selected)
- window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`)
+ // Keep react-router's entry state (idx/key/usr): only the URL changes.
+ window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
return { profile: selected }
}
From 837e659011e1a4e87235da06b63bb974ee76b3d2 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Sun, 25 Oct 2026 22:42:45 +0000
Subject: [PATCH 051/114] fix(webapp): ask the selected profile for live status
with no session open
With no session open, the sidebar's session.active_list poll goes out on
the active socket and names no profile. A profile picked on top of the
shared primary socket (a Webapp tab's live pick or ?profile= boot, a
native global remote) is only a view of that socket, so the gateway
answered for its own profile and the sidebar dots showed that profile's
turns instead of the selected one's.
The poll now names the selected profile when the active connection is
sharedPrimary and no session is open. With a session open the request is
unchanged: the session RPC dispatcher still routes it to the session's
owner.
Refs #144648 (WA-10)
Refs #144705
---
.../hooks/use-background-sync.test.tsx | 50 ++++++++++++++++++-
.../app/contrib/hooks/use-background-sync.ts | 21 +++++++-
2 files changed, 69 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx
index a8cc4e3d95..5cd998ffdf 100644
--- a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx
+++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx
@@ -1,11 +1,12 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { HermesConnection } from '@/global'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $sidebarShowArchived } from '@/store/layout'
import { $changeEventsAvailable, $cronChangeTick, $sessionsChangeTick } from '@/store/live-sync'
import { $onBattery } from '@/store/power'
-import { $activeSessionId } from '@/store/session'
+import { $activeSessionId, $connection, $selectedStoredSessionId } from '@/store/session'
import {
$sessionStates,
$workingSessionIds,
@@ -135,6 +136,53 @@ describe('useBackgroundSync profile-scoped session refresh', () => {
})
})
+describe('useBackgroundSync live-status poll with no session open', () => {
+ // A profile picked on top of the shared primary socket (a Webapp tab's live pick, a global remote)
+ // is a view of that socket: a request that names no profile is answered for the socket's own
+ // profile, so the sidebar dot would show that profile's turns instead of the selected one's.
+ const sharedResearch = { profile: 'research', sharedPrimary: true } as HermesConnection
+
+ async function polledParams(connection: HermesConnection | null) {
+ $connection.set(connection)
+ const request = vi.fn(async (_method: string, _params?: Record) => ({ sessions: [] }))
+ render('research', 'local', async () => undefined, request as typeof requestGateway)
+ await act(async () => undefined)
+
+ return request.mock.calls.filter(([method]) => method === 'session.active_list').map(([, params]) => params)
+ }
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ $activeSessionId.set(null)
+ $selectedStoredSessionId.set(null)
+ $changeEventsAvailable.set(false)
+ $sessionsChangeTick.set(0)
+ })
+
+ afterEach(() => {
+ cleanup()
+ $connection.set(null)
+ $selectedStoredSessionId.set(null)
+ vi.useRealTimers()
+ })
+
+ it('names the selected profile when it rides the shared primary socket', async () => {
+ expect(await polledParams(sharedResearch)).toEqual([{ profile: 'research' }])
+ })
+
+ it('asks what it asked before while a session is open (the session owner routes it)', async () => {
+ $selectedStoredSessionId.set('s-open')
+
+ expect(await polledParams(sharedResearch)).toEqual([{}])
+ })
+
+ it('names no profile on a socket of the selected profile’s own', async () => {
+ expect(await polledParams({ profile: 'research' } as HermesConnection)).toEqual([{}])
+ cleanup()
+ expect(await polledParams(null)).toEqual([{}])
+ })
+})
+
describe('useBackgroundSync keeps a quiet working turn live', () => {
// A foreground tool emits nothing between tool.start and tool.complete; the
// backend is the only witness that the turn is still running.
diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts
index 537f331d58..d3f5208722 100644
--- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts
+++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts
@@ -28,6 +28,7 @@ import { refreshProjects, refreshProjectTree } from '@/store/projects'
import {
$activeSessionId,
$busy,
+ $connection,
$currentCwd,
$messagingSessions,
$selectedStoredSessionId,
@@ -37,6 +38,7 @@ import {
sessionMatchesStoredId,
setCurrentCwd
} from '@/store/session'
+import { $focusedStoredSessionId } from '@/store/session-focus'
import type { SessionOwnerRoute } from '@/store/session-request-router'
import {
$sessionStates,
@@ -907,6 +909,20 @@ export function rehydrateLiveSessionStatuses(
confirmReconnectSettlesExcept(workingStoredIds)
}
+/** Params for the live-status poll. With a session open the session RPC
+ * dispatcher routes it to that session's owner, so it names nothing. With none
+ * open it rides the active socket, and a profile that shares the primary socket
+ * (a Webapp tab's live pick, a global remote) is only reached by naming it: the
+ * socket answers an unnamed request for its own profile, and the sidebar would
+ * paint that profile's turns instead of the selected one's. */
+function liveStatusParams(activeGatewayProfile: string): Record {
+ if ($focusedStoredSessionId.get() || $connection.get()?.sharedPrimary !== true) {
+ return {}
+ }
+
+ return { profile: activeGatewayProfile }
+}
+
/** Forget every profile's live-runtime bookkeeping. A gateway wipe already
* drops the session states these ids point at, so a carried-over set would
* only reap runtimes that no longer exist. */
@@ -1278,7 +1294,10 @@ export function useBackgroundSync({
const stateAtRequest = $sessionStates.get()
try {
- const response = await requestGateway('session.active_list', {})
+ const response = await requestGateway(
+ 'session.active_list',
+ liveStatusParams(activeGatewayProfile)
+ )
if (!cancelled) {
rehydrateLiveSessionStatuses(response, Date.now(), activeGatewayProfile, stateAtRequest)
From 719583b227c32bf277b1b736685a76d1654914df Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Sun, 25 Oct 2026 23:10:39 +0000
Subject: [PATCH 052/114] fix(webapp): keep naming the picked profile in the
live-status poll after a reconnect
A reconnect re-resolves the socket's own route and republishes the
primary's descriptor (sharedPrimary false), while the picked profile stays
selected. The no-session live-status poll read only that descriptor, so it
went back to asking the launch profile.
When the published remote descriptor isn't the selected profile's own, the
poll now asks main for the selected profile's route with the same check
requestGatewayForProfile routes by (sharedPrimaryRoute, now exported). With
a session open, or on a local descriptor, it asks nothing new and sends
what it sent before.
Refs #144648 (WA-10)
---
.../hooks/use-background-sync.test.tsx | 37 +++++++++++++++++++
.../app/contrib/hooks/use-background-sync.ts | 31 ++++++++++++----
apps/desktop/src/store/gateway.ts | 2 +-
3 files changed, 62 insertions(+), 8 deletions(-)
diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx
index 5cd998ffdf..93a0e4663d 100644
--- a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx
+++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx
@@ -2,6 +2,7 @@ import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesConnection } from '@/global'
+import { installBrowserDesktopBridge } from '@/lib/browser-desktop-bridge'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $sidebarShowArchived } from '@/store/layout'
import { $changeEventsAvailable, $cronChangeTick, $sessionsChangeTick } from '@/store/live-sync'
@@ -16,6 +17,7 @@ import {
publishSessionState
} from '@/store/session-states'
import { loadArchivedSessions } from '@/store/sidebar-archive'
+import { clearWebappSession, seedWebappSession } from '@/test/webapp-session'
import { useBackgroundSync } from './use-background-sync'
@@ -181,6 +183,41 @@ describe('useBackgroundSync live-status poll with no session open', () => {
cleanup()
expect(await polledParams(null)).toEqual([{}])
})
+
+ // A reconnect re-resolves the socket's own route and publishes the primary's descriptor
+ // (sharedPrimary false) while the picked profile stays selected; the poll must still name it.
+ it('keeps naming the picked profile after a reconnect republishes the primary descriptor', async () => {
+ const win = window as unknown as Window & { __HERMES_DASHBOARD_PROFILE__?: string }
+ win.__HERMES_DASHBOARD_PROFILE__ = 'default'
+ seedWebappSession('served-token')
+ window.history.replaceState(null, '', '/?profile=research#/')
+
+ try {
+ expect(installBrowserDesktopBridge()).toBe(true)
+ const desktop = window.hermesDesktop!
+
+ const request = vi.fn(async (_method: string, _params?: Record) => ({ sessions: [] }))
+ $connection.set(await desktop.getConnection('research'))
+ render('research', 'local', async () => undefined, request as typeof requestGateway)
+ await act(async () => undefined)
+
+ $connection.set(await desktop.getConnection())
+ expect($connection.get()?.sharedPrimary).toBeFalsy()
+ await act(async () => {
+ $sessionsChangeTick.set(1)
+ })
+ await act(async () => undefined)
+
+ expect(
+ request.mock.calls.filter(([method]) => method === 'session.active_list').map(([, params]) => params)
+ ).toEqual([{ profile: 'research' }, { profile: 'research' }])
+ } finally {
+ delete win.__HERMES_DASHBOARD_PROFILE__
+ clearWebappSession()
+ Reflect.deleteProperty(window, 'hermesDesktop')
+ window.history.replaceState(null, '', '/#/')
+ }
+ })
})
describe('useBackgroundSync keeps a quiet working turn live', () => {
diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts
index d3f5208722..7dd4a88c4c 100644
--- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts
+++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts
@@ -19,11 +19,11 @@ import {
import { createClientSessionState } from '@/lib/chat-runtime'
import { sessionListFingerprint, sessionMessagesSignature } from '@/lib/session-signatures'
import { latestSessionTodos, latestSessionTodoSnapshot } from '@/lib/todos'
-import { pendingSessionReplay } from '@/store/gateway'
+import { pendingSessionReplay, sharedPrimaryRoute } from '@/store/gateway'
import { $sidebarShowArchived } from '@/store/layout'
import { $changeEventsAvailable, $cronChangeTick, $projectsChangeTick, $sessionsChangeTick } from '@/store/live-sync'
import { $onBattery, batteryPollInterval } from '@/store/power'
-import { refreshActiveProfile } from '@/store/profile'
+import { normalizeProfileKey, refreshActiveProfile } from '@/store/profile'
import { refreshProjects, refreshProjectTree } from '@/store/projects'
import {
$activeSessionId,
@@ -914,13 +914,30 @@ export function rehydrateLiveSessionStatuses(
* open it rides the active socket, and a profile that shares the primary socket
* (a Webapp tab's live pick, a global remote) is only reached by naming it: the
* socket answers an unnamed request for its own profile, and the sidebar would
- * paint that profile's turns instead of the selected one's. */
-function liveStatusParams(activeGatewayProfile: string): Record {
- if ($focusedStoredSessionId.get() || $connection.get()?.sharedPrimary !== true) {
+ * paint that profile's turns instead of the selected one's.
+ *
+ * The published descriptor is not enough on its own: a reconnect republishes
+ * the primary's own descriptor (sharedPrimary false) while the picked profile
+ * stays selected. So a remote descriptor that isn't the selected profile's own
+ * asks main for the selected profile's route, the check requestGatewayForProfile
+ * routes by. A local descriptor never shares the primary and asks nothing. */
+async function liveStatusParams(activeGatewayProfile: string): Promise> {
+ const connection = $connection.get()
+
+ if ($focusedStoredSessionId.get() || !connection) {
return {}
}
- return { profile: activeGatewayProfile }
+ if (connection.sharedPrimary === true) {
+ return { profile: activeGatewayProfile }
+ }
+
+ const ownSocket =
+ connection.mode !== 'remote' ||
+ (Boolean(connection.profile?.trim()) &&
+ normalizeProfileKey(connection.profile) === normalizeProfileKey(activeGatewayProfile))
+
+ return !ownSocket && (await sharedPrimaryRoute(activeGatewayProfile)) ? { profile: activeGatewayProfile } : {}
}
/** Forget every profile's live-runtime bookkeeping. A gateway wipe already
@@ -1296,7 +1313,7 @@ export function useBackgroundSync({
try {
const response = await requestGateway(
'session.active_list',
- liveStatusParams(activeGatewayProfile)
+ await liveStatusParams(activeGatewayProfile)
)
if (!cancelled) {
diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts
index 1199833590..c83afe99fd 100644
--- a/apps/desktop/src/store/gateway.ts
+++ b/apps/desktop/src/store/gateway.ts
@@ -1124,7 +1124,7 @@ function createSecondary(profile: string, connectionId: null | string = null): S
// the second dial fails (tunnel/token are per-backend) and the closed socket
// poisons the active gateway with "not connected" even though the primary is
// open right next to it.
-async function sharedPrimaryRoute(profile: string): Promise {
+export async function sharedPrimaryRoute(profile: string): Promise {
const desktop = window.hermesDesktop
if (!desktop) {
From a9a4af9fdc5592596066c4246fa0e203d1a7ca81 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 01:04:01 +0000
Subject: [PATCH 053/114] fix(webapp): name the picked profile in the
live-status poll after a local reconnect
Native local profiles share the primary socket too (multiplex-only routing,
case 6). A primary reconnect republishes the primary's own unflagged local
descriptor while the picked profile stays selected, and the local-mode
shortcut then sent an unnamed poll answered for the launch profile. Drop the
shortcut: any descriptor that isn't the selected profile's own asks main for
the selected profile's shared route. The selected profile's own descriptor
still sends {} with no extra call.
---
.../hooks/use-background-sync.test.tsx | 42 +++++++++++++++++++
.../app/contrib/hooks/use-background-sync.ts | 21 +++++-----
2 files changed, 53 insertions(+), 10 deletions(-)
diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx
index 93a0e4663d..f33fe7fb7b 100644
--- a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx
+++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx
@@ -218,6 +218,48 @@ describe('useBackgroundSync live-status poll with no session open', () => {
window.history.replaceState(null, '', '/#/')
}
})
+
+ describe('on a native local gateway', () => {
+ // Native local profiles ride the one host backend too (multiplex-only routing): main
+ // resolves 'research' to the primary socket with sharedPrimary, and the primary's own
+ // descriptor to an unflagged local one.
+ const getConnection = vi.fn(async (profile?: string) =>
+ profile === 'research'
+ ? ({ mode: 'local', profile: 'research', sharedPrimary: true } as HermesConnection)
+ : ({ mode: 'local', profile: 'default' } as HermesConnection)
+ )
+
+ beforeEach(() => {
+ getConnection.mockClear()
+ Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { getConnection } })
+ })
+
+ afterEach(() => {
+ Reflect.deleteProperty(window, 'hermesDesktop')
+ })
+
+ it('keeps naming the picked profile after a reconnect republishes the primary local descriptor', async () => {
+ const request = vi.fn(async (_method: string, _params?: Record) => ({ sessions: [] }))
+ $connection.set(await getConnection('research'))
+ render('research', 'local', async () => undefined, request as typeof requestGateway)
+ await act(async () => undefined)
+
+ $connection.set(await getConnection())
+ await act(async () => {
+ $sessionsChangeTick.set(1)
+ })
+ await act(async () => undefined)
+
+ expect(
+ request.mock.calls.filter(([method]) => method === 'session.active_list').map(([, params]) => params)
+ ).toEqual([{ profile: 'research' }, { profile: 'research' }])
+ })
+
+ it('names no profile and asks main nothing on the selected profile’s own local descriptor', async () => {
+ expect(await polledParams({ mode: 'local', profile: 'research' } as HermesConnection)).toEqual([{}])
+ expect(getConnection).not.toHaveBeenCalled()
+ })
+ })
})
describe('useBackgroundSync keeps a quiet working turn live', () => {
diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts
index 7dd4a88c4c..7e55032e30 100644
--- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts
+++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts
@@ -912,15 +912,17 @@ export function rehydrateLiveSessionStatuses(
/** Params for the live-status poll. With a session open the session RPC
* dispatcher routes it to that session's owner, so it names nothing. With none
* open it rides the active socket, and a profile that shares the primary socket
- * (a Webapp tab's live pick, a global remote) is only reached by naming it: the
- * socket answers an unnamed request for its own profile, and the sidebar would
- * paint that profile's turns instead of the selected one's.
+ * (a Webapp tab's live pick, a native local profile, a global remote) is only
+ * reached by naming it: the socket answers an unnamed request for its own
+ * profile, and the sidebar would paint that profile's turns instead of the
+ * selected one's.
*
* The published descriptor is not enough on its own: a reconnect republishes
- * the primary's own descriptor (sharedPrimary false) while the picked profile
- * stays selected. So a remote descriptor that isn't the selected profile's own
- * asks main for the selected profile's route, the check requestGatewayForProfile
- * routes by. A local descriptor never shares the primary and asks nothing. */
+ * the primary's own descriptor (sharedPrimary false, local or remote) while the
+ * picked profile stays selected. So a descriptor that isn't the selected
+ * profile's own asks main for the selected profile's route, the check
+ * requestGatewayForProfile routes by. The selected profile's own descriptor
+ * asks nothing. */
async function liveStatusParams(activeGatewayProfile: string): Promise> {
const connection = $connection.get()
@@ -933,9 +935,8 @@ async function liveStatusParams(activeGatewayProfile: string): Promise
Date: Thu, 22 Oct 2026 12:01:11 +0300
Subject: [PATCH 054/114] fix(webapp): report a folder dropped on the composer
instead of ignoring it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
In the browser Webapp, a folder dropped on the composer (or the chat area,
or a message being edited) showed a copy cursor, attached nothing and said
nothing.
The browser bridge has no host path for a dropped File, and the drop
extractor discarded a directory without one before any composer saw it.
The extractor now keeps a path-less folder with its File, so it takes the
same attach pipeline as other unsupported drops. Staging refuses it with
a reason ("Folders can't be attached by dropping them. Use Attach >
Folder… instead."), shown as the usual "Could not attach " drop
warning in the composer and as an error toast in the edit composer. A
folder never takes the image path. Folders with a path (Electron) still
become @folder: refs.
Refs #93508 (review P3)
(cherry picked from commit 52f0b3fadceda6f11cca9644a8af5351051eb358)
---
.../chat/hooks/use-composer-actions.test.ts | 35 +++++++++++++++++++
.../app/chat/hooks/use-composer-actions.ts | 18 +++++++---
2 files changed, 49 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
index 02ac9961e3..f0356e2014 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
@@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $composerAttachments, type ComposerAttachment, updateComposerAttachment } from '@/store/composer'
+import { $notifications } from '@/store/notifications'
import { $connection } from '@/store/session'
import { droppedFileInlineRefs } from '../composer/inline-refs'
@@ -13,6 +14,7 @@ import {
HERMES_PATHS_MIME,
partitionDroppedFiles,
resolveImageAttachmentPreview,
+ stageDroppedFilePath,
useComposerActions
} from './use-composer-actions'
@@ -268,6 +270,18 @@ describe('extractDroppedFiles', () => {
expect(result[0]?.url).toBeUndefined()
})
+ // The browser Webapp has no host path for a dropped folder: report it, never drop it silently.
+ it('keeps a path-less dropped folder on the attach pipeline, where it is reported', async () => {
+ const transfer = stubTransfer([{ path: 'my-repo', isDirectory: true }])
+ stubBridge(transfer)
+
+ const result = extractDroppedFiles(transfer)
+
+ expect(result).toEqual([{ file: expect.any(File), isDirectory: true, path: '' }])
+ expect(partitionDroppedFiles(result).osDrops).toHaveLength(1)
+ await expect(stageDroppedFilePath(result[0]!)).rejects.toThrow("Folders can't be attached by dropping them")
+ })
+
it('does not duplicate a folder that appears in both items and files', () => {
// Chromium lists a dropped folder in transfer.files too (as a size-0 File);
// the items pass claims its path first so the files fallback skips it.
@@ -391,6 +405,27 @@ describe('useComposerActions native image drops', () => {
expect(add).not.toHaveBeenCalled()
})
+ it('warns about a dropped folder it cannot attach instead of staging its File', async () => {
+ const stageFileForAttach = vi.fn(async () => '/srv/uploads/web-my-repo')
+ const value = { getPathForFile: () => '', stageFileForAttach }
+ Object.defineProperty(window, 'hermesDesktop', { configurable: true, value })
+
+ const { result } = renderHook(() =>
+ useComposerActions({ activeSessionId: null, currentCwd: '/w', requestGateway: vi.fn() })
+ )
+
+ let attached = true
+
+ await act(async () => {
+ attached = await result.current.attachDroppedItems([{ file: new File([], 'shots.png'), isDirectory: true, path: '' }])
+ })
+
+ expect(attached).toBe(false)
+ expect(stageFileForAttach).not.toHaveBeenCalled()
+ expect($notifications.get().at(-1)).toMatchObject({ kind: 'warning' })
+ expect($notifications.get().at(-1)?.message).toContain("Could not attach shots.png: Folders can't be attached")
+ })
+
it('copies dropped screenshot bytes before trusting a transient macOS path', async () => {
const transientPath =
'/var/folders/x7/example/T/TemporaryItems/NSIRD_screencaptureui_4roSuW/Screen Shot 2026-08-11.png'
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
index b0e87b8500..5938621014 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
@@ -47,6 +47,11 @@ export function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
+/** A dropped File the composer sends as an image; never a folder, which has no bytes. */
+function isDroppedImage(file: File, filePath: string, isDirectory?: boolean): boolean {
+ return !isDirectory && (file.type.startsWith('image/') || isImagePath(file.name) || isImagePath(filePath))
+}
+
/**
* Read an attachment's thumbnail preview, local disk first. Paperclip picks,
* clipboard saves, and OS drops always hand us paths on THIS machine — the
@@ -119,6 +124,11 @@ export async function stageDroppedFilePath(candidate: DroppedFile): Promise
Date: Sun, 25 Oct 2026 22:44:43 +0000
Subject: [PATCH 055/114] fix(webapp): report every item a mixed drop could not
attach
A drop on the composer warned about a failed item only when nothing in the
drop attached, and then only about the last failure. In a mixed drop (a
file that stages plus a folder, or a file over the upload limit) the
failed items vanished without a word.
attachDroppedItems now collects every failure and shows them in one
"Drop files" warning whenever any item failed, alongside the chips for
the items that did attach.
Refs #144648 (WA-1)
Refs #144704
---
.../chat/hooks/use-composer-actions.test.ts | 45 +++++++++++++++++++
.../app/chat/hooks/use-composer-actions.ts | 17 +++----
2 files changed, 54 insertions(+), 8 deletions(-)
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
index f0356e2014..bef75451b1 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
@@ -426,6 +426,51 @@ describe('useComposerActions native image drops', () => {
expect($notifications.get().at(-1)?.message).toContain("Could not attach shots.png: Folders can't be attached")
})
+ // A drop that attached some files must still name the ones it left out.
+ it('reports the items a mixed drop could not attach alongside the ones it did', async () => {
+ const stageFileForAttach = vi.fn(async (file: File) => {
+ if (file.name === 'big.zip') {
+ throw new Error('Upload request is too large')
+ }
+
+ return `/srv/uploads/web-${file.name}`
+ })
+
+ const value = { getPathForFile: () => '', stageFileForAttach }
+ Object.defineProperty(window, 'hermesDesktop', { configurable: true, value })
+
+ const add = vi.fn()
+
+ const { result } = renderHook(() =>
+ useComposerActions({
+ activeSessionId: null,
+ currentCwd: '/w',
+ requestGateway: vi.fn(),
+ scope: { add, remove: vi.fn(() => null), target: 'main', update: vi.fn(() => true), updateIfCurrent: vi.fn(() => true) }
+ })
+ )
+
+ let attached = false
+
+ await act(async () => {
+ attached = await result.current.attachDroppedItems([
+ { file: new File(['x'], 'notes.txt'), path: '' },
+ { file: new File(['x'], 'big.zip'), path: '' },
+ { file: new File([], 'my-repo'), isDirectory: true, path: '' }
+ ])
+ })
+
+ expect(attached).toBe(true)
+ expect(add).toHaveBeenCalledOnce()
+ expect(add).toHaveBeenCalledWith(expect.objectContaining({ path: '/srv/uploads/web-notes.txt' }))
+
+ const warning = $notifications.get().find(notice => notice.message.includes('big.zip'))
+
+ expect(warning).toMatchObject({ kind: 'warning' })
+ expect(warning?.message).toContain('Could not attach big.zip: Upload request is too large')
+ expect(warning?.message).toContain("Could not attach my-repo: Folders can't be attached")
+ })
+
it('copies dropped screenshot bytes before trusting a transient macOS path', async () => {
const transientPath =
'/var/folders/x7/example/T/TemporaryItems/NSIRD_screencaptureui_4roSuW/Screen Shot 2026-08-11.png'
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
index 5938621014..b5821cbacf 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
@@ -798,7 +798,8 @@ export function useComposerActions({
}
let attached = false
- let lastFailure: string | null = null
+ // Every item that failed, so a drop that attached some files still names the rest.
+ const failures: string[] = []
for (const candidate of candidates) {
const { file, isDirectory, path: knownPath } = candidate
@@ -812,7 +813,7 @@ export function useComposerActions({
continue
}
- lastFailure = `Could not attach folder ${knownPath || ''}`
+ failures.push(`Could not attach folder ${knownPath || ''}`)
continue
}
@@ -824,7 +825,7 @@ export function useComposerActions({
continue
}
- lastFailure = `Could not attach ${knownPath}`
+ failures.push(`Could not attach ${knownPath}`)
continue
}
@@ -835,7 +836,7 @@ export function useComposerActions({
continue
}
- lastFailure = `Could not attach ${knownPath || 'file'}`
+ failures.push(`Could not attach ${knownPath || 'file'}`)
continue
}
@@ -861,7 +862,7 @@ export function useComposerActions({
continue
}
- lastFailure = `Could not attach ${file.name || 'image'}`
+ failures.push(`Could not attach ${file.name || 'image'}`)
continue
}
@@ -875,11 +876,11 @@ export function useComposerActions({
continue
}
- lastFailure = staged.failure
+ failures.push(staged.failure)
}
- if (!attached && lastFailure) {
- notify({ kind: 'warning', title: copy.dropFiles, message: lastFailure })
+ if (failures.length) {
+ notify({ kind: 'warning', title: copy.dropFiles, message: failures.join('; ') })
}
return attached
From facefdfb24e0f3e4db97006e9281a6b5e0f504ee Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Sun, 25 Oct 2026 22:44:55 +0000
Subject: [PATCH 056/114] fix(webapp): name the 16 MiB limit when a browser
pick or drop is too large
In the browser, a picked or dropped file over 16 MiB went to
/api/chat/file-upload and came back as "Upload request is too large",
which doesn't say what the limit is. A picked image over the limit showed
nothing at all: pickImages left the rejected pick as an unhandled
rejection.
The browser keeps its 16 MiB limit. Native Desktop's 25 MiB is the cap on
/api/chat/image-upload, but a browser send reads a staged file back
through /api/fs/read-data-url, which stops at WEBAPP_ATTACHMENT_MAX_BYTES
(16 MiB), so a larger image could never be sent. stageBrowserFile now
refuses a file over the limit before uploading it, with a message that
names the limit, and pickImages reports a refused pick as an "Image
attach failed" error.
Refs #144648 (WA-2)
Refs #144704
---
.../app/chat/hooks/use-composer-actions.ts | 32 ++++++---
apps/desktop/src/lib/browser-uploads.test.ts | 70 +++++++++++++++++++
apps/desktop/src/lib/browser-uploads.ts | 7 ++
3 files changed, 98 insertions(+), 11 deletions(-)
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
index b5821cbacf..5ce2a90c17 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
@@ -675,16 +675,26 @@ export function useComposerActions({
)
const pickImages = useCallback(async () => {
- const paths = await selectDesktopPaths({
- title: copy.attachImages,
- defaultPath: currentCwd || undefined,
- filters: [
- {
- name: t.composer.images,
- extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff']
- }
- ]
- })
+ let paths: string[]
+
+ try {
+ paths = await selectDesktopPaths({
+ title: copy.attachImages,
+ defaultPath: currentCwd || undefined,
+ filters: [
+ {
+ name: t.composer.images,
+ extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff']
+ }
+ ]
+ })
+ } catch (err) {
+ // The browser stages a pick before returning its path; a refused stage (an image over the
+ // upload limit) is reported here, not left as an unhandled rejection.
+ notifyError(err, copy.imageAttachFailed)
+
+ return
+ }
if (!paths?.length) {
return
@@ -693,7 +703,7 @@ export function useComposerActions({
for (const path of paths) {
await attachImagePath(path)
}
- }, [attachImagePath, copy.attachImages, currentCwd, t.composer.images])
+ }, [attachImagePath, copy.attachImages, copy.imageAttachFailed, currentCwd, t.composer.images])
const pasteClipboardImage = useCallback(
async ({ silent = false }: { silent?: boolean } = {}) => {
diff --git a/apps/desktop/src/lib/browser-uploads.test.ts b/apps/desktop/src/lib/browser-uploads.test.ts
index ba83fb2a6b..5a676ef371 100644
--- a/apps/desktop/src/lib/browser-uploads.test.ts
+++ b/apps/desktop/src/lib/browser-uploads.test.ts
@@ -1,5 +1,8 @@
+import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useComposerActions } from '@/app/chat/hooks/use-composer-actions'
+import { $notifications, clearNotifications } from '@/store/notifications'
import { $connection } from '@/store/session'
import { clearWebappSession, seedWebappSession } from '@/test/webapp-session'
@@ -7,6 +10,8 @@ import { installBrowserDesktopBridge } from './browser-desktop-bridge'
import { BROWSER_UPLOAD_MAX_BYTES } from './browser-uploads'
afterEach(() => {
+ cleanup()
+ clearNotifications()
clearWebappSession()
Reflect.deleteProperty(window, 'hermesDesktop')
document.documentElement.removeAttribute('data-hermes-desktop-host')
@@ -54,3 +59,68 @@ describe('browser image paste size', () => {
expect((fetchMock.mock.calls[0] as [URL])[0].pathname).toBe('/api/chat/image-upload')
})
})
+
+describe('browser file stage size', () => {
+ // A picked or dropped file over the upload limit gets the server's 413 "Upload request is too large",
+ // which doesn't say what the limit is, and a refused image pick used to show nothing at all.
+ const tooLarge = (name: string, type: string) => new File([new Uint8Array(BROWSER_UPLOAD_MAX_BYTES + 1)], name, { type })
+ const limitMessage = (name: string) => `${name} is larger than 16 MiB, the largest file the browser can attach.`
+
+ async function installBrowser() {
+ seedWebappSession('served-token')
+
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = new URL(String(input), window.location.origin)
+
+ if (url.pathname === '/api/chat/file-upload') {
+ return new Response(JSON.stringify({ detail: 'Upload request is too large' }), { status: 413 })
+ }
+
+ throw new Error(`Unexpected fetch: ${url}`)
+ })
+
+ vi.stubGlobal('fetch', fetchMock)
+ expect(installBrowserDesktopBridge()).toBe(true)
+ $connection.set(await window.hermesDesktop.getConnection('owner'))
+
+ const uploads = () => fetchMock.mock.calls.filter(([input]) => String(input).includes('/api/chat/file-upload'))
+
+ return { uploads }
+ }
+
+ const renderComposer = () =>
+ renderHook(() => useComposerActions({ activeSessionId: null, currentCwd: '/workspace', requestGateway: vi.fn() }))
+
+ it('refuses a picked image over the limit, names the limit, and uploads nothing', async () => {
+ const { uploads } = await installBrowser()
+ const { result } = renderComposer()
+
+ vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(function (this: HTMLInputElement) {
+ Object.defineProperty(this, 'files', { configurable: true, value: [tooLarge('huge.png', 'image/png')] })
+ this.dispatchEvent(new Event('change'))
+ })
+
+ await act(async () => {
+ await result.current.pickImages()
+ })
+
+ expect(uploads()).toHaveLength(0)
+ expect($notifications.get()).toContainEqual(expect.objectContaining({ kind: 'error', message: limitMessage('huge.png') }))
+ })
+
+ it('names the limit when a dropped file over it cannot be attached', async () => {
+ const { uploads } = await installBrowser()
+ const { result } = renderComposer()
+ let attached = true
+
+ await act(async () => {
+ attached = await result.current.attachDroppedItems([{ file: tooLarge('huge.zip', 'application/zip'), path: '' }])
+ })
+
+ expect(attached).toBe(false)
+ expect(uploads()).toHaveLength(0)
+ expect($notifications.get()).toContainEqual(
+ expect.objectContaining({ kind: 'warning', message: `Could not attach huge.zip: ${limitMessage('huge.zip')}` })
+ )
+ })
+})
diff --git a/apps/desktop/src/lib/browser-uploads.ts b/apps/desktop/src/lib/browser-uploads.ts
index 651cc3b6e5..0e5b8fccb0 100644
--- a/apps/desktop/src/lib/browser-uploads.ts
+++ b/apps/desktop/src/lib/browser-uploads.ts
@@ -94,6 +94,13 @@ async function stageBrowserFile(
profile: null | string,
onStaged: OnStaged
): Promise {
+ // The server refuses a larger file anyway, with a 413 that doesn't name the limit: say it here.
+ if (file.size > BROWSER_UPLOAD_MAX_BYTES) {
+ throw new Error(
+ `${file.name || 'The file'} is larger than ${BROWSER_UPLOAD_MAX_BYTES / (1024 * 1024)} MiB, the largest file the browser can attach.`
+ )
+ }
+
const form = new FormData()
form.append('file', file, file.name || 'attachment')
From 1f12eb870426c7e6d82f768739f61ef0eee20e6f Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Sun, 25 Oct 2026 23:10:43 +0000
Subject: [PATCH 057/114] fix(webapp): bound the warning for a large drop that
could not attach
A drop of many failing items joined every failure into one toast message, which grows off screen because the message body does not scroll. Name the first three, count the rest with a localized 'and N more', and keep the full list in the notification's collapsible detail, which now scrolls inside its height cap.
---
.../chat/hooks/use-composer-actions.test.ts | 43 +++++++++++++++++++
.../app/chat/hooks/use-composer-actions.ts | 18 +++++++-
apps/desktop/src/components/notifications.tsx | 2 +-
apps/desktop/src/i18n/ar_chat.ts | 1 +
apps/desktop/src/i18n/de.ts | 1 +
apps/desktop/src/i18n/en.ts | 1 +
apps/desktop/src/i18n/es.ts | 1 +
apps/desktop/src/i18n/fr.ts | 1 +
apps/desktop/src/i18n/ja.ts | 1 +
apps/desktop/src/i18n/ru.ts | 1 +
apps/desktop/src/i18n/types.ts | 1 +
apps/desktop/src/i18n/zh-hant_chat.ts | 1 +
apps/desktop/src/i18n/zh.ts | 1 +
locales/_keys.desktop.json | 1 +
14 files changed, 71 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
index bef75451b1..8781e59ce3 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
@@ -471,6 +471,49 @@ describe('useComposerActions native image drops', () => {
expect(warning?.message).toContain("Could not attach my-repo: Folders can't be attached")
})
+ // 50 oversized files must not grow one toast off screen: name a few, count the rest.
+ it('bounds the warning for a large mixed drop and keeps the full list in its detail', async () => {
+ const stageFileForAttach = vi.fn(async (file: File) => {
+ if (file.name.startsWith('huge-')) {
+ throw new Error('Upload request is too large')
+ }
+
+ return `/srv/uploads/web-${file.name}`
+ })
+
+ const value = { getPathForFile: () => '', stageFileForAttach }
+ Object.defineProperty(window, 'hermesDesktop', { configurable: true, value })
+
+ const add = vi.fn()
+
+ const { result } = renderHook(() =>
+ useComposerActions({
+ activeSessionId: null,
+ currentCwd: '/w',
+ requestGateway: vi.fn(),
+ scope: { add, remove: vi.fn(() => null), target: 'main', update: vi.fn(() => true), updateIfCurrent: vi.fn(() => true) }
+ })
+ )
+
+ const huge = Array.from({ length: 50 }, (_, index) => ({ file: new File(['x'], `huge-${index}.zip`), path: '' }))
+
+ await act(async () => {
+ await result.current.attachDroppedItems([{ file: new File(['x'], 'notes.txt'), path: '' }, ...huge])
+ })
+
+ expect(add).toHaveBeenCalledOnce()
+
+ const warning = $notifications.get().find(notice => notice.message.includes('huge-0.zip'))
+
+ expect(warning).toMatchObject({ kind: 'warning' })
+ expect(warning?.message).toContain('huge-2.zip')
+ expect(warning?.message).not.toContain('huge-3.zip')
+ expect(warning?.message).toMatch(/and 47 more$/)
+ expect(warning?.message.length).toBeLessThan(300)
+ expect(warning?.detail?.split('\n')).toHaveLength(50)
+ expect(warning?.detail).toContain('huge-49.zip')
+ })
+
it('copies dropped screenshot bytes before trusting a transient macOS path', async () => {
const transientPath =
'/var/folders/x7/example/T/TemporaryItems/NSIRD_screencaptureui_4roSuW/Screen Shot 2026-08-11.png'
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
index 5ce2a90c17..451d11bbb1 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
@@ -47,6 +47,9 @@ export function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
+/** How many failed drop items the warning names before it counts the rest. */
+const DROP_FAILURES_SHOWN = 3
+
/** A dropped File the composer sends as an image; never a folder, which has no bytes. */
function isDroppedImage(file: File, filePath: string, isDirectory?: boolean): boolean {
return !isDirectory && (file.type.startsWith('image/') || isImagePath(file.name) || isImagePath(filePath))
@@ -890,12 +893,23 @@ export function useComposerActions({
}
if (failures.length) {
- notify({ kind: 'warning', title: copy.dropFiles, message: failures.join('; ') })
+ // Name the first few and count the rest so a large drop can't grow the toast
+ // off screen; the full list rides in the collapsible detail.
+ const hidden = failures.length - DROP_FAILURES_SHOWN
+ const shown = failures.slice(0, DROP_FAILURES_SHOWN).join('; ')
+ const more = copy.dropFilesMore
+
+ notify({
+ kind: 'warning',
+ title: copy.dropFiles,
+ message: hidden > 0 ? `${shown}; ${more(hidden)}` : shown,
+ ...(hidden > 0 ? { detail: failures.join('\n') } : {})
+ })
}
return attached
},
- [attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath, copy.dropFiles]
+ [attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath, copy.dropFiles, copy.dropFilesMore]
)
const removeAttachment = useCallback(
diff --git a/apps/desktop/src/components/notifications.tsx b/apps/desktop/src/components/notifications.tsx
index 8971db9dd4..a47b8df666 100644
--- a/apps/desktop/src/components/notifications.tsx
+++ b/apps/desktop/src/components/notifications.tsx
@@ -360,7 +360,7 @@ function NotificationDetail({ detail }: { detail: string }) {
{copy.details}
{detail}
diff --git a/apps/desktop/src/i18n/ar_chat.ts b/apps/desktop/src/i18n/ar_chat.ts
index f45249c6b2..e9479b249a 100644
--- a/apps/desktop/src/i18n/ar_chat.ts
+++ b/apps/desktop/src/i18n/ar_chat.ts
@@ -472,6 +472,7 @@ export const arChat = {
noClipboardImage: 'لا توجد صورة في الحافظة',
clipboardPasteFailed: 'فشل اللصق من الحافظة',
dropFiles: 'أفلت الملفات هنا',
+ dropFilesMore: (count: number) => `و${count} أخرى`,
handoff: {
pickPlatform: 'اختر وجهة',
success: platform => `تم التسليم إلى ${platform}. استأنف هنا في أي وقت.`,
diff --git a/apps/desktop/src/i18n/de.ts b/apps/desktop/src/i18n/de.ts
index 2349812799..7175c61ae9 100644
--- a/apps/desktop/src/i18n/de.ts
+++ b/apps/desktop/src/i18n/de.ts
@@ -5420,6 +5420,7 @@ export const deOverrides = {
noClipboardImage: 'Kein Bild in der Zwischenablage',
clipboardPasteFailed: 'Zwischenablage-Einfügen fehlgeschlagen',
dropFiles: 'Dateien ablegen',
+ dropFilesMore: (count: number) => `und ${count} weitere`,
handoff: {
pickPlatform: 'Ziel wählen',
success: platform => `Übergeben an ${platform}. Jederzeit hier fortsetzen.`,
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 14a6b4533b..d8d00b4aa0 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -4757,6 +4757,7 @@ export const en: Translations = {
noClipboardImage: 'No image found in clipboard',
clipboardPasteFailed: 'Clipboard paste failed',
dropFiles: 'Drop files',
+ dropFilesMore: (count: number) => `and ${count} more`,
handoff: {
pickPlatform: 'Choose a destination',
success: platform => `Handed off to ${platform}. Resume here anytime.`,
diff --git a/apps/desktop/src/i18n/es.ts b/apps/desktop/src/i18n/es.ts
index d906e8fb6f..2abe705691 100644
--- a/apps/desktop/src/i18n/es.ts
+++ b/apps/desktop/src/i18n/es.ts
@@ -5404,6 +5404,7 @@ export const esOverrides = {
noClipboardImage: 'No se encontró ninguna imagen en el portapapeles',
clipboardPasteFailed: 'No se pudo pegar desde el portapapeles',
dropFiles: 'Soltar archivos',
+ dropFilesMore: (count: number) => `y ${count} más`,
handoff: {
pickPlatform: 'Elige un destino',
success: platform => `Transferido a ${platform}. Puedes reanudar aquí cuando quieras.`,
diff --git a/apps/desktop/src/i18n/fr.ts b/apps/desktop/src/i18n/fr.ts
index 539bcc970b..d0c02b2cb4 100644
--- a/apps/desktop/src/i18n/fr.ts
+++ b/apps/desktop/src/i18n/fr.ts
@@ -5427,6 +5427,7 @@ export const frOverrides = {
noClipboardImage: 'Aucune image trouvée dans le presse-papiers',
clipboardPasteFailed: 'Échec du collage depuis le presse-papiers',
dropFiles: 'Déposer des fichiers',
+ dropFilesMore: (count: number) => `et ${count} de plus`,
handoff: {
pickPlatform: 'Choisissez une destination',
success: platform => `Transféré vers ${platform}. Reprenez ici à tout moment.`,
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 5b4c66d295..f4362cce7e 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -3643,6 +3643,7 @@ export const jaOverrides = {
noClipboardImage: 'クリップボードに画像が見つかりません',
clipboardPasteFailed: 'クリップボードからの貼り付けに失敗しました',
dropFiles: 'ファイルをドロップ',
+ dropFilesMore: (count: number) => `ほか ${count} 件`,
handoff: {
pickPlatform: '送信先を選択',
success: platform => `${platform} に引き継ぎました。いつでもここで再開できます。`,
diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts
index a85f52d51f..1c0af102ee 100644
--- a/apps/desktop/src/i18n/ru.ts
+++ b/apps/desktop/src/i18n/ru.ts
@@ -3790,6 +3790,7 @@ export const ruOverrides = {
noClipboardImage: 'Изображение в буфере обмена не найдено',
clipboardPasteFailed: 'Вставка из буфера обмена не удалась',
dropFiles: 'Перетащите файлы',
+ dropFilesMore: (count: number) => `и ещё ${count}`,
handoff: {
pickPlatform: 'Выберите назначение',
success: platform => `Передаём в ${platform}. Возобновите здесь в любой момент.`,
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 08a6ce8600..05d4247c3f 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -4201,6 +4201,7 @@ catalog: CatalogTranslations
noClipboardImage: string
clipboardPasteFailed: string
dropFiles: string
+ dropFilesMore: (count: number) => string
handoff: {
pickPlatform: string
success: (platform: string) => string
diff --git a/apps/desktop/src/i18n/zh-hant_chat.ts b/apps/desktop/src/i18n/zh-hant_chat.ts
index f825769dbe..9c55c122dc 100644
--- a/apps/desktop/src/i18n/zh-hant_chat.ts
+++ b/apps/desktop/src/i18n/zh-hant_chat.ts
@@ -494,6 +494,7 @@ export const zhHantChat = {
noClipboardImage: '剪貼簿中沒有圖片',
clipboardPasteFailed: '剪貼簿貼上失敗',
dropFiles: '拖曳檔案',
+ dropFilesMore: (count: number) => `以及另外 ${count} 項`,
handoff: {
pickPlatform: '選擇目標平台',
success: platform => `已移交到 ${platform}。隨時可在此處恢復。`,
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 18c168f528..ad3e622188 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -4617,6 +4617,7 @@ export const zhOverrides = {
noClipboardImage: '剪贴板中没有图片',
clipboardPasteFailed: '粘贴剪贴板失败',
dropFiles: '拖放文件',
+ dropFilesMore: (count: number) => `以及另外 ${count} 项`,
handoff: {
pickPlatform: '选择目标平台',
success: platform => `已移交到 ${platform}。随时可在此处恢复。`,
diff --git a/locales/_keys.desktop.json b/locales/_keys.desktop.json
index cf5e7ea73a..4da7036232 100644
--- a/locales/_keys.desktop.json
+++ b/locales/_keys.desktop.json
@@ -1519,6 +1519,7 @@
"desktop.downloadImage",
"desktop.downloadStarted",
"desktop.dropFiles",
+ "desktop.dropFilesMore",
"desktop.editFailed",
"desktop.editTurnUnavailable",
"desktop.emptySlashCommand",
From 31b9f9eee6f12df158be646d2f5d3551a64b076a Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 01:03:00 +0000
Subject: [PATCH 058/114] test(webapp): move the composer drop-warning tests
into their own file
use-composer-actions.test.ts had grown past 1,000 lines. The three drop-warning tests (rejected folders, failed uploads, the bounded large-drop warning) move unchanged into use-composer-actions.drop-warnings.test.ts.
---
...use-composer-actions.drop-warnings.test.ts | 124 ++++++++++++++++++
.../chat/hooks/use-composer-actions.test.ts | 110 ----------------
2 files changed, 124 insertions(+), 110 deletions(-)
create mode 100644 apps/desktop/src/app/chat/hooks/use-composer-actions.drop-warnings.test.ts
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.drop-warnings.test.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.drop-warnings.test.ts
new file mode 100644
index 0000000000..21ed119908
--- /dev/null
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.drop-warnings.test.ts
@@ -0,0 +1,124 @@
+import { act, renderHook } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { $notifications } from '@/store/notifications'
+
+import { useComposerActions } from './use-composer-actions'
+
+// A drop item the composer cannot attach is reported in one bounded warning, never dropped silently.
+describe('useComposerActions drop warnings', () => {
+ afterEach(() => {
+ Reflect.deleteProperty(window, 'hermesDesktop')
+ vi.unstubAllGlobals()
+ vi.clearAllMocks()
+ })
+
+ it('warns about a dropped folder it cannot attach instead of staging its File', async () => {
+ const stageFileForAttach = vi.fn(async () => '/srv/uploads/web-my-repo')
+ const value = { getPathForFile: () => '', stageFileForAttach }
+ Object.defineProperty(window, 'hermesDesktop', { configurable: true, value })
+
+ const { result } = renderHook(() =>
+ useComposerActions({ activeSessionId: null, currentCwd: '/w', requestGateway: vi.fn() })
+ )
+
+ let attached = true
+
+ await act(async () => {
+ attached = await result.current.attachDroppedItems([{ file: new File([], 'shots.png'), isDirectory: true, path: '' }])
+ })
+
+ expect(attached).toBe(false)
+ expect(stageFileForAttach).not.toHaveBeenCalled()
+ expect($notifications.get().at(-1)).toMatchObject({ kind: 'warning' })
+ expect($notifications.get().at(-1)?.message).toContain("Could not attach shots.png: Folders can't be attached")
+ })
+
+ // A drop that attached some files must still name the ones it left out.
+ it('reports the items a mixed drop could not attach alongside the ones it did', async () => {
+ const stageFileForAttach = vi.fn(async (file: File) => {
+ if (file.name === 'big.zip') {
+ throw new Error('Upload request is too large')
+ }
+
+ return `/srv/uploads/web-${file.name}`
+ })
+
+ const value = { getPathForFile: () => '', stageFileForAttach }
+ Object.defineProperty(window, 'hermesDesktop', { configurable: true, value })
+
+ const add = vi.fn()
+
+ const { result } = renderHook(() =>
+ useComposerActions({
+ activeSessionId: null,
+ currentCwd: '/w',
+ requestGateway: vi.fn(),
+ scope: { add, remove: vi.fn(() => null), target: 'main', update: vi.fn(() => true), updateIfCurrent: vi.fn(() => true) }
+ })
+ )
+
+ let attached = false
+
+ await act(async () => {
+ attached = await result.current.attachDroppedItems([
+ { file: new File(['x'], 'notes.txt'), path: '' },
+ { file: new File(['x'], 'big.zip'), path: '' },
+ { file: new File([], 'my-repo'), isDirectory: true, path: '' }
+ ])
+ })
+
+ expect(attached).toBe(true)
+ expect(add).toHaveBeenCalledOnce()
+ expect(add).toHaveBeenCalledWith(expect.objectContaining({ path: '/srv/uploads/web-notes.txt' }))
+
+ const warning = $notifications.get().find(notice => notice.message.includes('big.zip'))
+
+ expect(warning).toMatchObject({ kind: 'warning' })
+ expect(warning?.message).toContain('Could not attach big.zip: Upload request is too large')
+ expect(warning?.message).toContain("Could not attach my-repo: Folders can't be attached")
+ })
+
+ // 50 oversized files must not grow one toast off screen: name a few, count the rest.
+ it('bounds the warning for a large mixed drop and keeps the full list in its detail', async () => {
+ const stageFileForAttach = vi.fn(async (file: File) => {
+ if (file.name.startsWith('huge-')) {
+ throw new Error('Upload request is too large')
+ }
+
+ return `/srv/uploads/web-${file.name}`
+ })
+
+ const value = { getPathForFile: () => '', stageFileForAttach }
+ Object.defineProperty(window, 'hermesDesktop', { configurable: true, value })
+
+ const add = vi.fn()
+
+ const { result } = renderHook(() =>
+ useComposerActions({
+ activeSessionId: null,
+ currentCwd: '/w',
+ requestGateway: vi.fn(),
+ scope: { add, remove: vi.fn(() => null), target: 'main', update: vi.fn(() => true), updateIfCurrent: vi.fn(() => true) }
+ })
+ )
+
+ const huge = Array.from({ length: 50 }, (_, index) => ({ file: new File(['x'], `huge-${index}.zip`), path: '' }))
+
+ await act(async () => {
+ await result.current.attachDroppedItems([{ file: new File(['x'], 'notes.txt'), path: '' }, ...huge])
+ })
+
+ expect(add).toHaveBeenCalledOnce()
+
+ const warning = $notifications.get().find(notice => notice.message.includes('huge-0.zip'))
+
+ expect(warning).toMatchObject({ kind: 'warning' })
+ expect(warning?.message).toContain('huge-2.zip')
+ expect(warning?.message).not.toContain('huge-3.zip')
+ expect(warning?.message).toMatch(/and 47 more$/)
+ expect(warning?.message.length).toBeLessThan(300)
+ expect(warning?.detail?.split('\n')).toHaveLength(50)
+ expect(warning?.detail).toContain('huge-49.zip')
+ })
+})
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
index 8781e59ce3..290b00b9fb 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
@@ -2,7 +2,6 @@ import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $composerAttachments, type ComposerAttachment, updateComposerAttachment } from '@/store/composer'
-import { $notifications } from '@/store/notifications'
import { $connection } from '@/store/session'
import { droppedFileInlineRefs } from '../composer/inline-refs'
@@ -405,115 +404,6 @@ describe('useComposerActions native image drops', () => {
expect(add).not.toHaveBeenCalled()
})
- it('warns about a dropped folder it cannot attach instead of staging its File', async () => {
- const stageFileForAttach = vi.fn(async () => '/srv/uploads/web-my-repo')
- const value = { getPathForFile: () => '', stageFileForAttach }
- Object.defineProperty(window, 'hermesDesktop', { configurable: true, value })
-
- const { result } = renderHook(() =>
- useComposerActions({ activeSessionId: null, currentCwd: '/w', requestGateway: vi.fn() })
- )
-
- let attached = true
-
- await act(async () => {
- attached = await result.current.attachDroppedItems([{ file: new File([], 'shots.png'), isDirectory: true, path: '' }])
- })
-
- expect(attached).toBe(false)
- expect(stageFileForAttach).not.toHaveBeenCalled()
- expect($notifications.get().at(-1)).toMatchObject({ kind: 'warning' })
- expect($notifications.get().at(-1)?.message).toContain("Could not attach shots.png: Folders can't be attached")
- })
-
- // A drop that attached some files must still name the ones it left out.
- it('reports the items a mixed drop could not attach alongside the ones it did', async () => {
- const stageFileForAttach = vi.fn(async (file: File) => {
- if (file.name === 'big.zip') {
- throw new Error('Upload request is too large')
- }
-
- return `/srv/uploads/web-${file.name}`
- })
-
- const value = { getPathForFile: () => '', stageFileForAttach }
- Object.defineProperty(window, 'hermesDesktop', { configurable: true, value })
-
- const add = vi.fn()
-
- const { result } = renderHook(() =>
- useComposerActions({
- activeSessionId: null,
- currentCwd: '/w',
- requestGateway: vi.fn(),
- scope: { add, remove: vi.fn(() => null), target: 'main', update: vi.fn(() => true), updateIfCurrent: vi.fn(() => true) }
- })
- )
-
- let attached = false
-
- await act(async () => {
- attached = await result.current.attachDroppedItems([
- { file: new File(['x'], 'notes.txt'), path: '' },
- { file: new File(['x'], 'big.zip'), path: '' },
- { file: new File([], 'my-repo'), isDirectory: true, path: '' }
- ])
- })
-
- expect(attached).toBe(true)
- expect(add).toHaveBeenCalledOnce()
- expect(add).toHaveBeenCalledWith(expect.objectContaining({ path: '/srv/uploads/web-notes.txt' }))
-
- const warning = $notifications.get().find(notice => notice.message.includes('big.zip'))
-
- expect(warning).toMatchObject({ kind: 'warning' })
- expect(warning?.message).toContain('Could not attach big.zip: Upload request is too large')
- expect(warning?.message).toContain("Could not attach my-repo: Folders can't be attached")
- })
-
- // 50 oversized files must not grow one toast off screen: name a few, count the rest.
- it('bounds the warning for a large mixed drop and keeps the full list in its detail', async () => {
- const stageFileForAttach = vi.fn(async (file: File) => {
- if (file.name.startsWith('huge-')) {
- throw new Error('Upload request is too large')
- }
-
- return `/srv/uploads/web-${file.name}`
- })
-
- const value = { getPathForFile: () => '', stageFileForAttach }
- Object.defineProperty(window, 'hermesDesktop', { configurable: true, value })
-
- const add = vi.fn()
-
- const { result } = renderHook(() =>
- useComposerActions({
- activeSessionId: null,
- currentCwd: '/w',
- requestGateway: vi.fn(),
- scope: { add, remove: vi.fn(() => null), target: 'main', update: vi.fn(() => true), updateIfCurrent: vi.fn(() => true) }
- })
- )
-
- const huge = Array.from({ length: 50 }, (_, index) => ({ file: new File(['x'], `huge-${index}.zip`), path: '' }))
-
- await act(async () => {
- await result.current.attachDroppedItems([{ file: new File(['x'], 'notes.txt'), path: '' }, ...huge])
- })
-
- expect(add).toHaveBeenCalledOnce()
-
- const warning = $notifications.get().find(notice => notice.message.includes('huge-0.zip'))
-
- expect(warning).toMatchObject({ kind: 'warning' })
- expect(warning?.message).toContain('huge-2.zip')
- expect(warning?.message).not.toContain('huge-3.zip')
- expect(warning?.message).toMatch(/and 47 more$/)
- expect(warning?.message.length).toBeLessThan(300)
- expect(warning?.detail?.split('\n')).toHaveLength(50)
- expect(warning?.detail).toContain('huge-49.zip')
- })
-
it('copies dropped screenshot bytes before trusting a transient macOS path', async () => {
const transientPath =
'/var/folders/x7/example/T/TemporaryItems/NSIRD_screencaptureui_4roSuW/Screen Shot 2026-08-11.png'
From ef1e337f3b14afe248744c80bb07a477be62926e Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 01:03:43 +0000
Subject: [PATCH 059/114] fix(webapp): keep the 16 MiB limit in the warning for
a long-named file
The oversize reason carried the filename, so a name of about 150 characters pushed it past readableError's 180-character cap and the drop warning lost the limit. The reason no longer includes the name; the drop warning already adds it.
---
apps/desktop/src/lib/browser-uploads.test.ts | 22 +++++++++++++++++---
apps/desktop/src/lib/browser-uploads.ts | 4 +++-
2 files changed, 22 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/lib/browser-uploads.test.ts b/apps/desktop/src/lib/browser-uploads.test.ts
index 5a676ef371..2797ac12d0 100644
--- a/apps/desktop/src/lib/browser-uploads.test.ts
+++ b/apps/desktop/src/lib/browser-uploads.test.ts
@@ -64,7 +64,7 @@ describe('browser file stage size', () => {
// A picked or dropped file over the upload limit gets the server's 413 "Upload request is too large",
// which doesn't say what the limit is, and a refused image pick used to show nothing at all.
const tooLarge = (name: string, type: string) => new File([new Uint8Array(BROWSER_UPLOAD_MAX_BYTES + 1)], name, { type })
- const limitMessage = (name: string) => `${name} is larger than 16 MiB, the largest file the browser can attach.`
+ const limitMessage = 'The file is larger than 16 MiB, the largest file the browser can attach.'
async function installBrowser() {
seedWebappSession('served-token')
@@ -105,7 +105,7 @@ describe('browser file stage size', () => {
})
expect(uploads()).toHaveLength(0)
- expect($notifications.get()).toContainEqual(expect.objectContaining({ kind: 'error', message: limitMessage('huge.png') }))
+ expect($notifications.get()).toContainEqual(expect.objectContaining({ kind: 'error', message: limitMessage }))
})
it('names the limit when a dropped file over it cannot be attached', async () => {
@@ -120,7 +120,23 @@ describe('browser file stage size', () => {
expect(attached).toBe(false)
expect(uploads()).toHaveLength(0)
expect($notifications.get()).toContainEqual(
- expect.objectContaining({ kind: 'warning', message: `Could not attach huge.zip: ${limitMessage('huge.zip')}` })
+ expect.objectContaining({ kind: 'warning', message: `Could not attach huge.zip: ${limitMessage}` })
)
})
+
+ // The reason is the same for any name: a long one used to push it past readableError's length cap.
+ it('still names the limit when the dropped file has a long name', async () => {
+ await installBrowser()
+ const { result } = renderComposer()
+ const name = `${'a'.repeat(150)}.zip`
+
+ await act(async () => {
+ await result.current.attachDroppedItems([{ file: tooLarge(name, 'application/zip'), path: '' }])
+ })
+
+ const warning = $notifications.get().find(notice => notice.kind === 'warning')
+
+ expect(warning?.message).toMatch(/^Could not attach a+/)
+ expect(warning?.message).toContain(limitMessage)
+ })
})
diff --git a/apps/desktop/src/lib/browser-uploads.ts b/apps/desktop/src/lib/browser-uploads.ts
index 0e5b8fccb0..bc6fe238bd 100644
--- a/apps/desktop/src/lib/browser-uploads.ts
+++ b/apps/desktop/src/lib/browser-uploads.ts
@@ -95,9 +95,11 @@ async function stageBrowserFile(
onStaged: OnStaged
): Promise {
// The server refuses a larger file anyway, with a 413 that doesn't name the limit: say it here.
+ // The reason leaves out the name, which a drop warning adds: a long name would push the reason
+ // past readableError's length cap and lose the limit.
if (file.size > BROWSER_UPLOAD_MAX_BYTES) {
throw new Error(
- `${file.name || 'The file'} is larger than ${BROWSER_UPLOAD_MAX_BYTES / (1024 * 1024)} MiB, the largest file the browser can attach.`
+ `The file is larger than ${BROWSER_UPLOAD_MAX_BYTES / (1024 * 1024)} MiB, the largest file the browser can attach.`
)
}
From 8974b3e67982d0841f4ba358be5d5def4556a811 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 01:05:30 +0000
Subject: [PATCH 060/114] fix(webapp): give the drop warning a character budget
Capping the warning at three failures did not bound its length: three long names or paths were still joined in full. Names over 48 characters are now shortened with a middle ellipsis, the warning stops naming failures past 240 characters and counts the rest, and the full list rides in the detail whenever anything is shortened or left out. The formatting moves to drop-warning.ts.
---
.../src/app/chat/hooks/drop-warning.ts | 56 +++++++++++++++++++
...use-composer-actions.drop-warnings.test.ts | 29 ++++++++++
.../app/chat/hooks/use-composer-actions.ts | 36 ++++--------
3 files changed, 96 insertions(+), 25 deletions(-)
create mode 100644 apps/desktop/src/app/chat/hooks/drop-warning.ts
diff --git a/apps/desktop/src/app/chat/hooks/drop-warning.ts b/apps/desktop/src/app/chat/hooks/drop-warning.ts
new file mode 100644
index 0000000000..d67cb9d186
--- /dev/null
+++ b/apps/desktop/src/app/chat/hooks/drop-warning.ts
@@ -0,0 +1,56 @@
+/** A drop item the composer could not attach: what was dropped, and why when it is known. */
+export interface DropFailure {
+ name: string
+ reason?: string
+}
+
+/** How many failed drop items the warning names before it counts the rest. */
+const DROP_FAILURES_SHOWN = 3
+
+/** The longest name the warning shows whole; a longer one keeps its start and its end. */
+const DROP_FAILURE_NAME_CHARS = 48
+
+/** The warning names failures until this many characters, then counts the rest. */
+const DROP_WARNING_CHARS = 240
+
+function middleEllipsis(text: string, max: number): string {
+ if (text.length <= max) {
+ return text
+ }
+
+ const head = Math.ceil((max - 1) / 2)
+
+ return `${text.slice(0, head)}…${text.slice(text.length - (max - 1 - head))}`
+}
+
+function failureText({ name, reason }: DropFailure, maxName = Infinity): string {
+ const label = `Could not attach ${middleEllipsis(name, maxName)}`
+
+ return reason ? `${label}: ${reason}` : label
+}
+
+/** One bounded warning for a drop's failures: shortened names, a few of them, and a count of the
+ * rest, so a large drop or long paths can't grow the toast off screen. Whenever anything is
+ * shortened or left out, the full list rides in the collapsible detail. */
+export function dropWarning(
+ failures: DropFailure[],
+ more: (count: number) => string
+): { detail?: string; message: string } {
+ const shown: string[] = []
+
+ for (const failure of failures.slice(0, DROP_FAILURES_SHOWN)) {
+ const text = failureText(failure, DROP_FAILURE_NAME_CHARS)
+
+ if (shown.length && [...shown, text].join('; ').length > DROP_WARNING_CHARS) {
+ break
+ }
+
+ shown.push(text)
+ }
+
+ const hidden = failures.length - shown.length
+ const message = hidden > 0 ? `${shown.join('; ')}; ${more(hidden)}` : shown.join('; ')
+ const full = failures.map(failure => failureText(failure))
+
+ return message === full.join('; ') ? { message } : { detail: full.join('\n'), message }
+}
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.drop-warnings.test.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.drop-warnings.test.ts
index 21ed119908..a232f51ca1 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.drop-warnings.test.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.drop-warnings.test.ts
@@ -121,4 +121,33 @@ describe('useComposerActions drop warnings', () => {
expect(warning?.detail?.split('\n')).toHaveLength(50)
expect(warning?.detail).toContain('huge-49.zip')
})
+
+ // Three failures can still overflow when their names are long: shorten them, keep them whole in the detail.
+ it('bounds the warning for long names and keeps every full name in its detail', async () => {
+ const stageFileForAttach = vi.fn(async () => {
+ throw new Error('Upload request is too large')
+ })
+
+ const value = { getPathForFile: () => '', stageFileForAttach }
+ Object.defineProperty(window, 'hermesDesktop', { configurable: true, value })
+
+ const { result } = renderHook(() =>
+ useComposerActions({ activeSessionId: null, currentCwd: '/w', requestGateway: vi.fn() })
+ )
+
+ const names = Array.from({ length: 3 }, (_, index) => `long-${index}-${'x'.repeat(200)}.zip`)
+
+ await act(async () => {
+ await result.current.attachDroppedItems(names.map(name => ({ file: new File(['x'], name), path: '' })))
+ })
+
+ const warning = $notifications.get().find(notice => notice.message.includes('long-0-'))
+
+ expect(warning).toMatchObject({ kind: 'warning' })
+ expect(warning?.message.length).toBeLessThan(300)
+ expect(warning?.message).toContain('…')
+ expect(warning?.message).toContain('Upload request is too large')
+ expect(warning?.message).toMatch(/and \d+ more$/)
+ expect(warning?.detail?.split('\n')).toEqual(names.map(name => `Could not attach ${name}: Upload request is too large`))
+ })
})
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
index 451d11bbb1..39fb2a3427 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
@@ -24,6 +24,8 @@ import { notify, notifyError, readableError } from '@/store/notifications'
import type { ImageDetachResponse } from '../../types'
+import { type DropFailure, dropWarning } from './drop-warning'
+
const IMAGE_EXTENSION_PATTERN = /\.(png|jpe?g|gif|webp|bmp|tiff?|svg|ico)$/i
const BLOB_MIME_EXTENSION: Record = {
@@ -47,9 +49,6 @@ export function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
-/** How many failed drop items the warning names before it counts the rest. */
-const DROP_FAILURES_SHOWN = 3
-
/** A dropped File the composer sends as an image; never a folder, which has no bytes. */
function isDroppedImage(file: File, filePath: string, isDirectory?: boolean): boolean {
return !isDirectory && (file.type.startsWith('image/') || isImagePath(file.name) || isImagePath(filePath))
@@ -141,15 +140,13 @@ export async function stageDroppedFilePath(candidate: DroppedFile): Promise {
- const failure = `Could not attach ${label}`
-
+async function stageDropForChip(candidate: DroppedFile, name: string): Promise<{ failure: DropFailure; path: string }> {
try {
- return { failure, path: await stageDroppedFilePath(candidate) }
+ return { failure: { name }, path: await stageDroppedFilePath(candidate) }
} catch (error) {
const reason = readableError(error, '').message
- return { failure: reason ? `${failure}: ${reason}` : failure, path: '' }
+ return { failure: reason ? { name, reason } : { name }, path: '' }
}
}
@@ -812,7 +809,7 @@ export function useComposerActions({
let attached = false
// Every item that failed, so a drop that attached some files still names the rest.
- const failures: string[] = []
+ const failures: DropFailure[] = []
for (const candidate of candidates) {
const { file, isDirectory, path: knownPath } = candidate
@@ -826,7 +823,7 @@ export function useComposerActions({
continue
}
- failures.push(`Could not attach folder ${knownPath || ''}`)
+ failures.push({ name: `folder ${knownPath || ''}` })
continue
}
@@ -838,7 +835,7 @@ export function useComposerActions({
continue
}
- failures.push(`Could not attach ${knownPath}`)
+ failures.push({ name: knownPath })
continue
}
@@ -849,7 +846,7 @@ export function useComposerActions({
continue
}
- failures.push(`Could not attach ${knownPath || 'file'}`)
+ failures.push({ name: knownPath || 'file' })
continue
}
@@ -875,7 +872,7 @@ export function useComposerActions({
continue
}
- failures.push(`Could not attach ${file.name || 'image'}`)
+ failures.push({ name: file.name || 'image' })
continue
}
@@ -893,18 +890,7 @@ export function useComposerActions({
}
if (failures.length) {
- // Name the first few and count the rest so a large drop can't grow the toast
- // off screen; the full list rides in the collapsible detail.
- const hidden = failures.length - DROP_FAILURES_SHOWN
- const shown = failures.slice(0, DROP_FAILURES_SHOWN).join('; ')
- const more = copy.dropFilesMore
-
- notify({
- kind: 'warning',
- title: copy.dropFiles,
- message: hidden > 0 ? `${shown}; ${more(hidden)}` : shown,
- ...(hidden > 0 ? { detail: failures.join('\n') } : {})
- })
+ notify({ kind: 'warning', title: copy.dropFiles, ...dropWarning(failures, copy.dropFilesMore) })
}
return attached
From 988cc79005d6c6b3f224f1b94f9ddea82afa410a Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 01:28:32 +0000
Subject: [PATCH 061/114] fix(webapp): name the file when a drop into a message
being edited cannot be staged
The oversize reason no longer carries the filename (2bd8355307ff), and the edit composer passed the staging error straight to notifyError, so an oversized file dropped into a message being edited said only 'The file is larger than 16 MiB...' and several such files gave identical errors. The edit composer now collects staging failures and reports them in one dropWarning error toast, as the main composer does: each name is shown, shortened past 48 characters, the limit stays, and the full names ride in the detail. Upload failures after staging are reported as before. No new strings.
---
.../thread/user-edit-composer.tsx | 31 +++++++--
.../user-message-edit-drop-staging.test.tsx | 68 ++++++++++++++++---
2 files changed, 84 insertions(+), 15 deletions(-)
diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
index 0404c80ccf..7a4c9d3fff 100644
--- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
@@ -51,6 +51,7 @@ import { detectTrigger, openDirectiveScope, textBeforeCaret, type TriggerState }
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
import { isRedoShortcut, isUndoShortcut } from '@/app/chat/composer/undo-history'
import { chipTypedUrlOnSpace, linkifyUrls } from '@/app/chat/composer/url-refs'
+import { type DropFailure, dropWarning } from '@/app/chat/hooks/drop-warning'
import {
extractDroppedFiles,
HERMES_PATHS_MIME,
@@ -78,7 +79,7 @@ import { Loader2Icon } from '@/lib/icons'
import { isMacPlatform } from '@/lib/platform'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
-import { notifyError } from '@/store/notifications'
+import { notify, notifyError, readableError } from '@/store/notifications'
import { $terminalBackend } from '@/store/session'
import { ambientRequestFor } from '@/store/session-gone-latch'
import { isSessionRemote, requestForOwnedSession } from '@/store/session-states'
@@ -474,16 +475,30 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess
requestForOwnedSession(sessionId, ambient, method, params)
const refs: InlineRefInput[] = []
+ // The staging reason leaves out the name (the main composer's drop warning adds it): name
+ // each rejected file here too, in the same bounded warning, so several aren't told apart.
+ const failures: DropFailure[] = []
for (const candidate of osDrops) {
+ let path: string
+
try {
// A rejected stage carries the server's or a proxy's reason (an nginx 413).
- const path = await stageDroppedFilePath(candidate)
+ path = await stageDroppedFilePath(candidate)
+ } catch (err) {
+ const name = candidate.file?.name || candidate.path || 'file'
+ const reason = readableError(err, '').message
- if (!path) {
- continue
- }
+ failures.push(reason ? { name, reason } : { name })
+ continue
+ }
+
+ if (!path) {
+ continue
+ }
+
+ try {
const kind: ComposerAttachment['kind'] =
candidate.file?.type.startsWith('image/') || isImagePath(candidate.file?.name || path) ? 'image' : 'file'
@@ -502,9 +517,13 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess
}
}
+ if (failures.length) {
+ notify({ kind: 'error', title: t.desktop.dropFiles, ...dropWarning(failures, t.desktop.dropFilesMore) })
+ }
+
return refs
},
- [cwd, gateway, sessionId, t.desktop.dropFiles]
+ [cwd, gateway, sessionId, t.desktop.dropFiles, t.desktop.dropFilesMore]
)
const resetDragState = useCallback(() => {
diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message-edit-drop-staging.test.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message-edit-drop-staging.test.tsx
index 1281ced320..3f3578c126 100644
--- a/apps/desktop/src/components/assistant-ui/thread/user-message-edit-drop-staging.test.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/user-message-edit-drop-staging.test.tsx
@@ -50,16 +50,23 @@ function Harness({ gateway }: { gateway: HermesGateway }) {
)
}
-// A browser drop: a File with no native path (the Webapp shell has no getPathForFile).
-function fileDrop(file: File) {
+// A browser drop: Files with no native path (the Webapp shell has no getPathForFile).
+function fileDrop(...files: File[]) {
return {
- files: { item: (index: number) => (index === 0 ? file : null), length: 1 },
+ files: { item: (index: number) => files[index] ?? null, length: files.length },
getData: () => '',
- items: [{ getAsFile: () => file, kind: 'file', webkitGetAsEntry: () => null }],
+ items: files.map(file => ({ getAsFile: () => file, kind: 'file', webkitGetAsEntry: () => null })),
types: ['Files']
}
}
+async function openEditor(gateway: HermesGateway) {
+ render()
+ fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
+
+ return screen.findByRole('textbox', { name: 'Edit message' })
+}
+
describe('edit composer drop staging failures', () => {
it('reports why a dropped file could not be staged', async () => {
const stageFileForAttach = vi.fn(async () => {
@@ -68,10 +75,7 @@ describe('edit composer drop staging failures', () => {
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { stageFileForAttach } })
const request = vi.fn(async () => ({}))
- render()
-
- fireEvent.click(await screen.findByRole('button', { name: 'Edit message' }))
- const editor = await screen.findByRole('textbox', { name: 'Edit message' })
+ const editor = await openEditor({ request } as unknown as HermesGateway)
await act(async () => {
fireEvent.drop(editor, { dataTransfer: fileDrop(new File(['payload'], 'notes.txt', { type: 'text/plain' })) })
@@ -79,8 +83,54 @@ describe('edit composer drop staging failures', () => {
await waitFor(() => expect(stageFileForAttach).toHaveBeenCalledOnce())
await waitFor(() =>
- expect($notifications.get()).toEqual([expect.objectContaining({ kind: 'error', message: UPLOAD_413 })])
+ expect($notifications.get()).toEqual([
+ expect.objectContaining({ kind: 'error', message: `Could not attach notes.txt: ${UPLOAD_413}` })
+ ])
)
expect(request).not.toHaveBeenCalledWith('file.attach', expect.anything())
})
+
+ // The oversize reason leaves out the name, so the edit composer must add it, or several
+ // oversized files in one drop give the same error. A long name is shortened; the limit stays.
+ it('names each oversized file of a mixed drop, bounded, and keeps the limit', async () => {
+ const limit = 'The file is larger than 16 MiB, the largest file the browser can attach.'
+ const longName = `${'a'.repeat(150)}.zip`
+
+ const stageFileForAttach = vi.fn(async (file: File) => {
+ if (file.name === 'notes.txt') {
+ return '/srv/uploads/web-notes.txt'
+ }
+
+ throw new Error(limit)
+ })
+
+ Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { stageFileForAttach } })
+ const editor = await openEditor({ request: vi.fn(async () => ({})) } as unknown as HermesGateway)
+
+ await act(async () => {
+ fireEvent.drop(editor, {
+ dataTransfer: fileDrop(
+ new File(['payload'], 'notes.txt', { type: 'text/plain' }),
+ new File(['x'], longName, { type: 'application/zip' }),
+ new File(['x'], 'big.zip', { type: 'application/zip' })
+ )
+ })
+ })
+
+ await waitFor(() => expect(stageFileForAttach).toHaveBeenCalledTimes(3))
+
+ const warning = await waitFor(() => {
+ const found = $notifications.get().find(notice => notice.message.includes('big.zip'))
+ expect(found).toBeDefined()
+
+ return found!
+ })
+
+ expect(warning.kind).toBe('error')
+ expect(warning.message).toContain(`Could not attach ${'a'.repeat(24)}…${'a'.repeat(19)}.zip: ${limit}`)
+ expect(warning.message).toContain(`Could not attach big.zip: ${limit}`)
+ expect(warning.message).not.toContain(longName)
+ expect(warning.message).not.toContain('notes.txt')
+ expect(warning.detail).toContain(`Could not attach ${longName}: ${limit}`)
+ })
})
From 3406295052b5d0d5cf8388e8b8f998d2257d9768 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 02:46:35 +0000
Subject: [PATCH 062/114] =?UTF-8?q?fix(webapp):=20report=20a=20refused=20A?=
=?UTF-8?q?ttach=20=E2=86=92=20Files=20pick=20instead=20of=20failing=20sil?=
=?UTF-8?q?ently?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
In the browser, Attach → Files… with a file over 16 MiB rejected inside selectDesktopPaths and nothing caught it, so the user saw nothing. pickContextPaths now reports the refusal under a new "Attach failed" title, as pickImages does.
---
.../app/chat/hooks/use-composer-actions.ts | 22 ++++++++++++++-----
apps/desktop/src/i18n/ar_chat.ts | 1 +
apps/desktop/src/i18n/de.ts | 1 +
apps/desktop/src/i18n/en.ts | 1 +
apps/desktop/src/i18n/es.ts | 1 +
apps/desktop/src/i18n/fr.ts | 1 +
apps/desktop/src/i18n/ja.ts | 1 +
apps/desktop/src/i18n/ru.ts | 1 +
apps/desktop/src/i18n/types.ts | 1 +
apps/desktop/src/i18n/zh-hant_chat.ts | 1 +
apps/desktop/src/i18n/zh.ts | 1 +
apps/desktop/src/lib/browser-uploads.test.ts | 20 +++++++++++++++++
locales/_keys.desktop.json | 1 +
13 files changed, 47 insertions(+), 6 deletions(-)
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
index 39fb2a3427..d69a7b92ec 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
@@ -513,11 +513,21 @@ export function useComposerActions({
const pickContextPaths = useCallback(
async (kind: 'file' | 'folder') => {
- const paths = await selectDesktopPaths({
- title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
- defaultPath: currentCwd || undefined,
- directories: kind === 'folder'
- })
+ let paths: string[]
+
+ try {
+ paths = await selectDesktopPaths({
+ title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
+ defaultPath: currentCwd || undefined,
+ directories: kind === 'folder'
+ })
+ } catch (err) {
+ // As in pickImages: the browser stages a pick before returning its path, and a refused
+ // stage (a file over the upload limit) is reported here, not left as an unhandled rejection.
+ notifyError(err, copy.attachFailed)
+
+ return
+ }
if (!paths?.length) {
return
@@ -536,7 +546,7 @@ export function useComposerActions({
})
}
},
- [attachToMain, currentCwd]
+ [attachToMain, copy.attachFailed, currentCwd]
)
const insertContextPathInlineRef = useCallback(
diff --git a/apps/desktop/src/i18n/ar_chat.ts b/apps/desktop/src/i18n/ar_chat.ts
index e9479b249a..2fa532e356 100644
--- a/apps/desktop/src/i18n/ar_chat.ts
+++ b/apps/desktop/src/i18n/ar_chat.ts
@@ -465,6 +465,7 @@ export const arChat = {
imageAttach: 'إرفاق الصورة',
imageWriteFailed: 'فشل كتابة الصورة',
imageAttachFailed: 'فشل إرفاق الصورة',
+ attachFailed: 'فشل الإرفاق',
pastedContent: 'محتوى ملصق',
pasteAttachFailed: 'تعذر إرفاق النص الملصق',
attachImages: 'إرفاق الصور',
diff --git a/apps/desktop/src/i18n/de.ts b/apps/desktop/src/i18n/de.ts
index 7175c61ae9..d366f8701d 100644
--- a/apps/desktop/src/i18n/de.ts
+++ b/apps/desktop/src/i18n/de.ts
@@ -5413,6 +5413,7 @@ export const deOverrides = {
imageAttach: 'Bild anhängen',
imageWriteFailed: 'Bild konnte nicht auf die Festplatte geschrieben werden.',
imageAttachFailed: 'Bild-Anhängen fehlgeschlagen',
+ attachFailed: 'Anhängen fehlgeschlagen',
pastedContent: 'Eingefügter Inhalt',
pasteAttachFailed: 'Eingefügter Text konnte nicht angehängt werden',
attachImages: 'Bilder anhängen',
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index d8d00b4aa0..abaf846b37 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -4750,6 +4750,7 @@ export const en: Translations = {
imageAttach: 'Image attach',
imageWriteFailed: 'Failed to write image to disk.',
imageAttachFailed: 'Image attach failed',
+ attachFailed: 'Attach failed',
pastedContent: 'Pasted content',
pasteAttachFailed: 'Could not attach pasted text',
attachImages: 'Attach images',
diff --git a/apps/desktop/src/i18n/es.ts b/apps/desktop/src/i18n/es.ts
index 2abe705691..214fe509e8 100644
--- a/apps/desktop/src/i18n/es.ts
+++ b/apps/desktop/src/i18n/es.ts
@@ -5397,6 +5397,7 @@ export const esOverrides = {
imageAttach: 'Adjuntar imagen',
imageWriteFailed: 'No se pudo escribir la imagen en disco.',
imageAttachFailed: 'No se pudo adjuntar la imagen',
+ attachFailed: 'No se pudo adjuntar',
pastedContent: 'Contenido pegado',
pasteAttachFailed: 'No se pudo adjuntar el texto pegado',
attachImages: 'Adjuntar imágenes',
diff --git a/apps/desktop/src/i18n/fr.ts b/apps/desktop/src/i18n/fr.ts
index d0c02b2cb4..c7234f2a21 100644
--- a/apps/desktop/src/i18n/fr.ts
+++ b/apps/desktop/src/i18n/fr.ts
@@ -5420,6 +5420,7 @@ export const frOverrides = {
imageAttach: 'Image jointe',
imageWriteFailed: "Échec de l'écriture de l'image sur le disque.",
imageAttachFailed: "Échec de l'ajout de l'image",
+ attachFailed: "Échec de l'ajout",
pastedContent: 'Contenu collé',
pasteAttachFailed: 'Impossible de joindre le texte collé',
attachImages: 'Joindre des images',
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index f4362cce7e..656c08d6b4 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -3636,6 +3636,7 @@ export const jaOverrides = {
imageAttach: '画像を添付',
imageWriteFailed: '画像のディスクへの書き込みに失敗しました。',
imageAttachFailed: '画像の添付に失敗しました',
+ attachFailed: '添付に失敗しました',
pastedContent: '貼り付けた内容',
pasteAttachFailed: '貼り付けたテキストを添付できませんでした',
attachImages: '画像を添付',
diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts
index 1c0af102ee..1fa8127263 100644
--- a/apps/desktop/src/i18n/ru.ts
+++ b/apps/desktop/src/i18n/ru.ts
@@ -3785,6 +3785,7 @@ export const ruOverrides = {
imageAttach: 'Прикрепление изображения',
imageWriteFailed: 'Не удалось записать изображение на диск.',
imageAttachFailed: 'Прикрепление изображения не удалось',
+ attachFailed: 'Прикрепление не удалось',
attachImages: 'Прикрепить изображения',
clipboard: 'Буфер обмена',
noClipboardImage: 'Изображение в буфере обмена не найдено',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 05d4247c3f..3977ed3fab 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -4194,6 +4194,7 @@ catalog: CatalogTranslations
imageAttach: string
imageWriteFailed: string
imageAttachFailed: string
+ attachFailed: string,
pastedContent: string
pasteAttachFailed: string
attachImages: string
diff --git a/apps/desktop/src/i18n/zh-hant_chat.ts b/apps/desktop/src/i18n/zh-hant_chat.ts
index 9c55c122dc..4788b2411b 100644
--- a/apps/desktop/src/i18n/zh-hant_chat.ts
+++ b/apps/desktop/src/i18n/zh-hant_chat.ts
@@ -487,6 +487,7 @@ export const zhHantChat = {
imageAttach: '附加圖片',
imageWriteFailed: '無法將圖片寫入磁碟。',
imageAttachFailed: '附加圖片失敗',
+ attachFailed: '附加失敗',
pastedContent: '貼上內容',
pasteAttachFailed: '無法附加貼上的文字',
attachImages: '附加圖片',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index ad3e622188..3ae27ad989 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -4610,6 +4610,7 @@ export const zhOverrides = {
imageAttach: '附加图片',
imageWriteFailed: '无法将图片写入磁盘。',
imageAttachFailed: '附加图片失败',
+ attachFailed: '附加失败',
pastedContent: '粘贴内容',
pasteAttachFailed: '无法附加粘贴的文本',
attachImages: '附加图片',
diff --git a/apps/desktop/src/lib/browser-uploads.test.ts b/apps/desktop/src/lib/browser-uploads.test.ts
index 2797ac12d0..58ca7596a6 100644
--- a/apps/desktop/src/lib/browser-uploads.test.ts
+++ b/apps/desktop/src/lib/browser-uploads.test.ts
@@ -108,6 +108,26 @@ describe('browser file stage size', () => {
expect($notifications.get()).toContainEqual(expect.objectContaining({ kind: 'error', message: limitMessage }))
})
+ // Attach → Files… used to leave the refused stage as an unhandled rejection, with nothing shown.
+ it('refuses a picked file over the limit with one notice naming the limit, and uploads nothing', async () => {
+ const { uploads } = await installBrowser()
+ const { result } = renderComposer()
+
+ vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(function (this: HTMLInputElement) {
+ Object.defineProperty(this, 'files', { configurable: true, value: [tooLarge('huge.zip', 'application/zip')] })
+ this.dispatchEvent(new Event('change'))
+ })
+
+ await act(async () => {
+ await result.current.pickContextPaths('file')
+ })
+
+ expect(uploads()).toHaveLength(0)
+ expect($notifications.get()).toEqual([
+ expect.objectContaining({ kind: 'error', message: limitMessage, title: 'Attach failed' })
+ ])
+ })
+
it('names the limit when a dropped file over it cannot be attached', async () => {
const { uploads } = await installBrowser()
const { result } = renderComposer()
diff --git a/locales/_keys.desktop.json b/locales/_keys.desktop.json
index 4da7036232..32c0634695 100644
--- a/locales/_keys.desktop.json
+++ b/locales/_keys.desktop.json
@@ -1501,6 +1501,7 @@
"cron.weekdaysAt",
"desktop.archiveFailed",
"desktop.archived",
+ "desktop.attachFailed",
"desktop.attachImages",
"desktop.audioReadFailed",
"desktop.branchFailed",
From 86130453a9364627cdbd6fb73008c2a0692301fd Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 02:47:43 +0000
Subject: [PATCH 063/114] fix(webapp): drop a reason's period before the next
item in the drop warning
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Each reason ends with a period and the warning joins items with '; ', so it read '…instead.; Could not attach…' and '…instead.; and 5 more'. A reason another item or the count follows now loses its trailing period; the last reason and the detail keep theirs.
---
.../src/app/chat/hooks/drop-warning.test.ts | 45 +++++++++++++++++++
.../src/app/chat/hooks/drop-warning.ts | 32 ++++++++-----
.../user-message-edit-drop-staging.test.tsx | 3 +-
3 files changed, 68 insertions(+), 12 deletions(-)
create mode 100644 apps/desktop/src/app/chat/hooks/drop-warning.test.ts
diff --git a/apps/desktop/src/app/chat/hooks/drop-warning.test.ts b/apps/desktop/src/app/chat/hooks/drop-warning.test.ts
new file mode 100644
index 0000000000..0a7825ce43
--- /dev/null
+++ b/apps/desktop/src/app/chat/hooks/drop-warning.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest'
+
+import { dropWarning } from './drop-warning'
+
+const more = (count: number) => `and ${count} more`
+const folder = "Folders can't be attached by dropping them. Use Attach → Folder… instead."
+const limit = 'The file is larger than 16 MiB, the largest file the browser can attach.'
+
+// Reasons are sentences; joined with '; ' their periods used to read '…instead.; Could not attach…'.
+describe('dropWarning', () => {
+ it('drops the period of a reason another item follows, and keeps the last one', () => {
+ const { detail, message } = dropWarning(
+ [
+ { name: 'somefolder', reason: folder },
+ { name: 'huge.zip', reason: limit }
+ ],
+ more
+ )
+
+ expect(message).toBe(
+ `Could not attach somefolder: ${folder.slice(0, -1)}; Could not attach huge.zip: ${limit}`
+ )
+ expect(message).not.toContain('.;')
+ expect(detail).toBeUndefined()
+ })
+
+ it('drops the period before the count of the rest, and keeps each reason whole in the detail', () => {
+ const failures = [
+ { name: 'somefolder', reason: folder },
+ ...Array.from({ length: 5 }, (_, index) => ({ name: `${'z'.repeat(135)}-${index}.zip`, reason: limit }))
+ ]
+
+ const { detail, message } = dropWarning(failures, more)
+
+ expect(message).toMatch(/[^.]; and \d+ more$/)
+ expect(message).not.toContain('.;')
+ expect(detail?.split('\n')).toEqual(failures.map(({ name, reason }) => `Could not attach ${name}: ${reason}`))
+ })
+
+ it('keeps a lone reason as it is, period included, with no detail', () => {
+ expect(dropWarning([{ name: 'huge.zip', reason: limit }], more)).toEqual({
+ message: `Could not attach huge.zip: ${limit}`
+ })
+ })
+})
diff --git a/apps/desktop/src/app/chat/hooks/drop-warning.ts b/apps/desktop/src/app/chat/hooks/drop-warning.ts
index d67cb9d186..4eb3c0e7f3 100644
--- a/apps/desktop/src/app/chat/hooks/drop-warning.ts
+++ b/apps/desktop/src/app/chat/hooks/drop-warning.ts
@@ -23,34 +23,44 @@ function middleEllipsis(text: string, max: number): string {
return `${text.slice(0, head)}…${text.slice(text.length - (max - 1 - head))}`
}
-function failureText({ name, reason }: DropFailure, maxName = Infinity): string {
+function failureText({ name, reason }: DropFailure, maxName = Infinity, joined = false): string {
const label = `Could not attach ${middleEllipsis(name, maxName)}`
+ // A reason ends with a period, which reads '….;' before the '; ' that joins the next item.
+ const text = joined ? reason?.replace(/\.$/, '') : reason
- return reason ? `${label}: ${reason}` : label
+ return text ? `${label}: ${text}` : label
+}
+
+/** The failures joined into one line, then the count of the rest when there is one. */
+function summaryText(failures: DropFailure[], maxName: number, rest?: string): string {
+ const items = failures.map((failure, index) =>
+ failureText(failure, maxName, rest !== undefined || index < failures.length - 1)
+ )
+
+ return (rest === undefined ? items : [...items, rest]).join('; ')
}
/** One bounded warning for a drop's failures: shortened names, a few of them, and a count of the
* rest, so a large drop or long paths can't grow the toast off screen. Whenever anything is
- * shortened or left out, the full list rides in the collapsible detail. */
+ * shortened or left out, the full list, each reason whole, rides in the collapsible detail. */
export function dropWarning(
failures: DropFailure[],
more: (count: number) => string
): { detail?: string; message: string } {
- const shown: string[] = []
+ const shown: DropFailure[] = []
for (const failure of failures.slice(0, DROP_FAILURES_SHOWN)) {
- const text = failureText(failure, DROP_FAILURE_NAME_CHARS)
-
- if (shown.length && [...shown, text].join('; ').length > DROP_WARNING_CHARS) {
+ if (shown.length && summaryText([...shown, failure], DROP_FAILURE_NAME_CHARS).length > DROP_WARNING_CHARS) {
break
}
- shown.push(text)
+ shown.push(failure)
}
const hidden = failures.length - shown.length
- const message = hidden > 0 ? `${shown.join('; ')}; ${more(hidden)}` : shown.join('; ')
- const full = failures.map(failure => failureText(failure))
+ const message = summaryText(shown, DROP_FAILURE_NAME_CHARS, hidden > 0 ? more(hidden) : undefined)
- return message === full.join('; ') ? { message } : { detail: full.join('\n'), message }
+ return message === summaryText(failures, Infinity)
+ ? { message }
+ : { detail: failures.map(failure => failureText(failure)).join('\n'), message }
}
diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message-edit-drop-staging.test.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message-edit-drop-staging.test.tsx
index 3f3578c126..fbca5c6ca7 100644
--- a/apps/desktop/src/components/assistant-ui/thread/user-message-edit-drop-staging.test.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/user-message-edit-drop-staging.test.tsx
@@ -127,7 +127,8 @@ describe('edit composer drop staging failures', () => {
})
expect(warning.kind).toBe('error')
- expect(warning.message).toContain(`Could not attach ${'a'.repeat(24)}…${'a'.repeat(19)}.zip: ${limit}`)
+ // A reason another item follows loses its period; the detail keeps it whole.
+ expect(warning.message).toContain(`Could not attach ${'a'.repeat(24)}…${'a'.repeat(19)}.zip: ${limit.slice(0, -1)}; `)
expect(warning.message).toContain(`Could not attach big.zip: ${limit}`)
expect(warning.message).not.toContain(longName)
expect(warning.message).not.toContain('notes.txt')
From 1034e667ab92fb02be5f338787e55d0be82e10c5 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 02:48:17 +0000
Subject: [PATCH 064/114] fix(webapp): show the edit composer drop summary as a
warning, like the main composer
The same refused drop showed as an error in a message being edited and as a warning in the main composer. The bounded summary is now a warning in both; upload failures after staging stay errors.
---
.../src/components/assistant-ui/thread/user-edit-composer.tsx | 3 ++-
.../thread/user-message-edit-drop-staging.test.tsx | 4 ++--
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
index 7a4c9d3fff..5de07aa71d 100644
--- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
@@ -517,8 +517,9 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess
}
}
+ // A warning, as in the main composer, so the same drop looks the same wherever it lands.
if (failures.length) {
- notify({ kind: 'error', title: t.desktop.dropFiles, ...dropWarning(failures, t.desktop.dropFilesMore) })
+ notify({ kind: 'warning', title: t.desktop.dropFiles, ...dropWarning(failures, t.desktop.dropFilesMore) })
}
return refs
diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message-edit-drop-staging.test.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message-edit-drop-staging.test.tsx
index fbca5c6ca7..2d1fa0f6ef 100644
--- a/apps/desktop/src/components/assistant-ui/thread/user-message-edit-drop-staging.test.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/user-message-edit-drop-staging.test.tsx
@@ -84,7 +84,7 @@ describe('edit composer drop staging failures', () => {
await waitFor(() => expect(stageFileForAttach).toHaveBeenCalledOnce())
await waitFor(() =>
expect($notifications.get()).toEqual([
- expect.objectContaining({ kind: 'error', message: `Could not attach notes.txt: ${UPLOAD_413}` })
+ expect.objectContaining({ kind: 'warning', message: `Could not attach notes.txt: ${UPLOAD_413}` })
])
)
expect(request).not.toHaveBeenCalledWith('file.attach', expect.anything())
@@ -126,7 +126,7 @@ describe('edit composer drop staging failures', () => {
return found!
})
- expect(warning.kind).toBe('error')
+ expect(warning.kind).toBe('warning')
// A reason another item follows loses its period; the detail keeps it whole.
expect(warning.message).toContain(`Could not attach ${'a'.repeat(24)}…${'a'.repeat(19)}.zip: ${limit.slice(0, -1)}; `)
expect(warning.message).toContain(`Could not attach big.zip: ${limit}`)
From e5ce5600589c2fa8c541889fb0fad894b69564f4 Mon Sep 17 00:00:00 2001
From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com>
Date: Thu, 22 Oct 2026 12:24:10 +0300
Subject: [PATCH 065/114] fix(webapp): redraw the foreground program after a
trimmed terminal replay
Reattaching a browser terminal whose full-screen program (vim, htop,
hermes --tui) had written more than 1 MiB on the alternate screen painted
fragments of its output, and nothing redrew until the window was resized
or Ctrl-L was pressed.
The reattach replay is the last 1 MiB of output, which then starts
mid-way through the program's screen updates. The resume RESIZE repeats
the unchanged size, and an unchanged TIOCSWINSZ sends no SIGWINCH (checked
on a real PTY: 0 signals; a one-row step and back: 1).
When the server reports a trimmed replay, the browser now steps the size
one row away before sending the real size, so the foreground program
redraws as after a window resize. No input reaches the program (host
shells must not get Ctrl-L), and the replay contract is unchanged. Known
limit: modes set before the trimmed tail (alternate screen, mouse
tracking) are not restored.
Refs #93508 (review P3)
(cherry picked from commit b6de9130b9873b23c0af5b76814efa1fd29e01ee)
---
apps/desktop/src/lib/browser-terminal.test.ts | 13 +++++++++++++
apps/desktop/src/lib/browser-terminal.ts | 9 ++++++++-
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/lib/browser-terminal.test.ts b/apps/desktop/src/lib/browser-terminal.test.ts
index 133541485b..2afbbdeeb8 100644
--- a/apps/desktop/src/lib/browser-terminal.test.ts
+++ b/apps/desktop/src/lib/browser-terminal.test.ts
@@ -129,6 +129,19 @@ describe('persistent browser terminals', () => {
expect(resumed.sent.join('')).not.toContain('\u000c')
})
+ // A trimmed replay can start mid-way through a full-screen program; an unchanged size sends no SIGWINCH.
+ it('forces one redraw by a real size change only when resuming from a trimmed replay', async () => {
+ const api = install()
+ await api.start({ cwd: '/original', restoreKey: 'tab-one' })
+ Socket.instances[0].close(1006)
+ await vi.advanceTimersByTimeAsync(1000)
+ expect(Socket.instances[1].sent).toEqual(['\u001b[RESIZE:80;24]'])
+ Socket.reply = socket => socket.metadata({ truncated: true })
+ Socket.instances[1].close(1006)
+ await vi.advanceTimersByTimeAsync(1000)
+ expect(Socket.instances[2].sent).toEqual(['\u001b[RESIZE:80;23]', '\u001b[RESIZE:80;24]'])
+ })
+
it('closes explicitly with a fresh authenticated request even while disconnected', async () => {
const api = install()
const session = await api.start({ cwd: '/original', restoreKey: 'tab-one' })
diff --git a/apps/desktop/src/lib/browser-terminal.ts b/apps/desktop/src/lib/browser-terminal.ts
index aeb1f72e1e..0e8df7b695 100644
--- a/apps/desktop/src/lib/browser-terminal.ts
+++ b/apps/desktop/src/lib/browser-terminal.ts
@@ -68,6 +68,13 @@ function settleOpen(state: TerminalState): void {
state.openedAt = null
}
+/** A trimmed replay can start mid-way through a full-screen program's output, and an
+ * unchanged size sends no SIGWINCH: step one row away first so the program redraws. */
+function sendResumeSize(socket: WebSocket, state: TerminalState, trimmed?: boolean): void {
+ if (trimmed) {socket.send(`\u001b[RESIZE:${state.cols};${state.rows > 1 ? state.rows - 1 : state.rows + 1}]`)}
+ socket.send(`\u001b[RESIZE:${state.cols};${state.rows}]`)
+}
+
async function closeSavedShell(saved: SavedTerminal, websocketUrl: BrowserTerminalOptions['websocketUrl']): Promise {
const url = new URL(await websocketUrl(saved.profile))
url.searchParams.set('attach', saved.terminalId)
@@ -210,7 +217,7 @@ async function dialTerminal(state: TerminalState, context: DialContext): Promise
handshakeComplete = true
// Resume query dimensions do not resize an existing server PTY.
- if (resuming) {socket.send(`\u001b[RESIZE:${state.cols};${state.rows}]`)}
+ if (resuming) {sendResumeSize(socket, state, meta.truncated)}
setStatus(state, 'open')
state.openedAt = Date.now()
From 14c34063a918f96bd6e5c9c0ddc64ba2d5655a53 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Sun, 25 Oct 2026 22:44:48 +0000
Subject: [PATCH 066/114] fix(webapp): close the browser terminal tab when its
host shell exits
Typing `exit` (or Ctrl-D) in a browser terminal left the tab open with
"Terminal expired or exited. Press Enter to create a new shell." Native
Desktop closes the tab when its shell exits.
The server closes the socket with 4410 both when the shell exits and when
it ends the shell itself (retention expiry, a deleted profile, the gateway
stopping), and the browser read every 4410 on a persistent shell as
`expired`.
A shell that exits by itself now closes with the reason "Process exited"
(the drain's EOF, and an attach to a shell that died while detached). The
browser reports that close as a process exit, so the session hook removes
the tab the way it does in native Desktop. A 4410 without the reason still
shows the expired message and keeps the tab. The dashboard chat only logs
the close reason, so it is unchanged.
Refs #144648 (WA-6)
Refs #144704
---
.../terminal/use-terminal-session.test.tsx | 15 +++++++
apps/desktop/src/lib/browser-terminal.test.ts | 25 +++++++++++
apps/desktop/src/lib/browser-terminal.ts | 6 +++
hermes_cli/pty_session.py | 7 +++-
tests/hermes_cli/test_pty_session.py | 42 +++++++++++++++++++
5 files changed, 93 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.test.tsx
index c7f5da78d3..74dce944a9 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.test.tsx
+++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.test.tsx
@@ -299,6 +299,21 @@ it('replays server history once, suppresses parser replies, and detaches on remo
expect(Socket.instances[2].url.searchParams.has('attach')).toBe(false)
})
+// Typing `exit` ends the host shell: the tab closes as it does in native Desktop, instead of
+// staying open as an expired terminal.
+it('closes a persistent browser tab when its host shell exits', async () => {
+ seedWebappSession('test-token')
+ Socket.persistent = true
+ vi.stubGlobal('WebSocket', Socket)
+ installBrowserDesktopBridge()
+ const id = createTerminal('/work')
+ render()
+ await waitFor(() => expect(screen.getByText('open')).toBeTruthy())
+ act(() => Socket.instances[0].close(4410, 'Process exited'))
+ expect($terminals.get().some(tab => tab.id === id)).toBe(false)
+ expect(emulator.output).not.toContain('Press Enter to create a new shell')
+})
+
it('close others and close all terminate mounted and unmounted persisted shells', async () => {
seedWebappSession('test-token')
Socket.persistent = true
diff --git a/apps/desktop/src/lib/browser-terminal.test.ts b/apps/desktop/src/lib/browser-terminal.test.ts
index 2afbbdeeb8..68a7ed21fe 100644
--- a/apps/desktop/src/lib/browser-terminal.test.ts
+++ b/apps/desktop/src/lib/browser-terminal.test.ts
@@ -187,6 +187,31 @@ describe('persistent browser terminals', () => {
expect(localStorage.length).toBe(0)
})
+ // The server closes with 4410 both when the shell exits (`exit`, Ctrl-D) and when it ends the
+ // shell itself (expiry, a deleted profile, shutdown); only an exit carries the reason.
+ it('reports a host shell that exited as an exit, as native Desktop does, and forgets it', async () => {
+ const api = install()
+ const session = await api.start({ cwd: '/original', restoreKey: 'tab-one' })
+ const exit = vi.fn()
+ api.onExit(session.id, exit)
+ Socket.instances[0].close(4410, 'Process exited')
+ await vi.advanceTimersByTimeAsync(30_000)
+ expect(Socket.instances).toHaveLength(1)
+ expect(exit).toHaveBeenCalledExactlyOnceWith({ code: null, signal: null })
+ expect(localStorage.length).toBe(0)
+ })
+
+ it('reports a 4410 close without the exit reason as expired', async () => {
+ const api = install()
+ const session = await api.start({ cwd: '/original', restoreKey: 'tab-one' })
+ const exit = vi.fn()
+ api.onExit(session.id, exit)
+ Socket.instances[0].close(4410)
+ await vi.advanceTimersByTimeAsync(30_000)
+ expect(Socket.instances).toHaveLength(1)
+ expect(exit).toHaveBeenCalledExactlyOnceWith({ code: null, signal: 'expired' })
+ })
+
it('never retries denied attachments or silently adopts the active profile', async () => {
const api = install()
const session = await api.start({ cwd: '/original', restoreKey: 'tab-one' })
diff --git a/apps/desktop/src/lib/browser-terminal.ts b/apps/desktop/src/lib/browser-terminal.ts
index 0e8df7b695..ecf437a55b 100644
--- a/apps/desktop/src/lib/browser-terminal.ts
+++ b/apps/desktop/src/lib/browser-terminal.ts
@@ -47,6 +47,9 @@ interface TerminalState {
}
const META = '\u0000HERMES_TERMINAL_META:'
+// 4410 also ends a shell the server closed (expiry, a deleted profile, shutdown); only a shell
+// that exited by itself (`exit`, Ctrl-D) carries this reason (pty_session.WS_CLOSE_PROCESS_EXITED_REASON).
+const PROCESS_EXITED_REASON = 'Process exited'
const MAX_RETRIES = 5
const RETRY_BACKOFF = { baseDelayMs: 500, capMs: 8000 }
const START_TIMEOUT = 10_000
@@ -267,6 +270,9 @@ async function dialTerminal(state: TerminalState, context: DialContext): Promise
if (!state.terminalId) {
finish(state, event.code === 4410 ? null : 'disconnected')
+ } else if (event.code === 4410 && event.reason === PROCESS_EXITED_REASON) {
+ // The shell ended: report a process exit, which closes the tab as in native Desktop.
+ finish(state, null)
} else if (signal !== 'disconnected') {
finish(state, signal)
} else {
diff --git a/hermes_cli/pty_session.py b/hermes_cli/pty_session.py
index 7b03019757..3d6d1aa7f2 100644
--- a/hermes_cli/pty_session.py
+++ b/hermes_cli/pty_session.py
@@ -19,6 +19,9 @@ from fastapi import WebSocketDisconnect
logger = logging.getLogger(__name__)
WS_CLOSE_PROCESS_EXITED = 4410
+# 4410 also ends a session the server closed (expiry, a deleted profile, shutdown). Only a child
+# that exited by itself carries this reason, so the Webapp terminal can close its tab on `exit`.
+WS_CLOSE_PROCESS_EXITED_REASON = "Process exited"
WS_CLOSE_SUPERSEDED = 4409
# Going Away: the viewer stopped taking output, but its session lives on and the same
# token reattaches. The dashboard chat tab and the Webapp terminal both redial on 1001;
@@ -140,7 +143,7 @@ class PtySession:
ws = self._ws
async with self._output_lock:
if self._ws is ws:
- await _close_ws(ws, WS_CLOSE_PROCESS_EXITED)
+ await _close_ws(ws, WS_CLOSE_PROCESS_EXITED, WS_CLOSE_PROCESS_EXITED_REASON)
return
if not chunk: # idle tick
await asyncio.sleep(0)
@@ -275,7 +278,7 @@ class PtySession:
return False
if not self.alive:
self.detach(ws)
- await _close_ws(ws, WS_CLOSE_PROCESS_EXITED)
+ await _close_ws(ws, WS_CLOSE_PROCESS_EXITED, WS_CLOSE_PROCESS_EXITED_REASON)
return False
if force_redraw:
return await self.write(ws, TUI_FORCE_REDRAW)
diff --git a/tests/hermes_cli/test_pty_session.py b/tests/hermes_cli/test_pty_session.py
index 20e09070e1..a2bd810bcd 100644
--- a/tests/hermes_cli/test_pty_session.py
+++ b/tests/hermes_cli/test_pty_session.py
@@ -58,6 +58,7 @@ class FakeWS:
def __init__(self):
self.sent = [] # list of ("bytes"|"text", payload)
self.close_code = None
+ self.close_reason = None
async def send_bytes(self, data):
self.sent.append(("bytes", bytes(data)))
@@ -67,6 +68,7 @@ class FakeWS:
async def close(self, code=1000, reason=""):
self.close_code = code
+ self.close_reason = reason
class FailingWS(FakeWS):
@@ -519,6 +521,46 @@ async def test_eof_marks_dead_and_closes_socket_4410():
await s.close()
+# 4410 also ends a shell the server closed (expiry, a deleted profile, shutdown). Only a shell that
+# exited by itself says so in the close reason, so a viewer can end its tab the way native Desktop
+# does when its shell exits, and keep the tab for the other cases.
+@pytest.mark.asyncio
+async def test_eof_close_says_the_process_exited():
+ from hermes_cli.pty_session import PtySession
+ s = PtySession("k", FakeBridge([b"logout", None]), buffer_cap=1024, read_timeout=0.01)
+ await s.start()
+ ws = FakeWS()
+ await s.attach(ws)
+ await async_wait_until(lambda: ws.close_code is not None, timeout=2.0)
+ assert (ws.close_code, ws.close_reason) == (4410, "Process exited")
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_attaching_to_a_shell_that_exited_while_detached_says_it_exited():
+ from hermes_cli.pty_session import PtySession
+ s = PtySession("k", FakeBridge([b"logout", None]), buffer_cap=1024, read_timeout=0.01)
+ await s.start()
+ await async_wait_until(lambda: not s.alive, timeout=2.0)
+ ws = FakeWS()
+ assert await s.attach(ws, initial_text="meta") is False
+ assert ws.sent == [("text", "meta"), ("bytes", b"logout")]
+ assert (ws.close_code, ws.close_reason) == (4410, "Process exited")
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_a_server_close_keeps_4410_without_the_exit_reason():
+ from hermes_cli.pty_session import PtySession
+ s = PtySession("k", FakeBridge([]), buffer_cap=1024, read_timeout=0.01)
+ await s.start()
+ ws = FakeWS()
+ await s.attach(ws)
+ await s.close()
+ assert ws.close_code == 4410
+ assert ws.close_reason != "Process exited"
+
+
from hermes_cli.pty_session import PtySessionRegistry, RegistryFull
From 60c7921bd3a7a269b2fd40846ba111e83b103601 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Sun, 25 Oct 2026 22:49:13 +0000
Subject: [PATCH 067/114] fix(webapp): stop giving terminal drops POSIX quoting
in shells it does not know
Dropping files into a terminal typed each path with POSIX single quotes
unless the shell name contained powershell, pwsh or cmd, or was fish, csh
or tcsh. nu, xonsh, elvish and an SSH tab's remote shell have other
quoting rules, so a name could end the quotes there. In cmd with delayed
expansion on, !NAME! inside the double quotes still expanded and a lone !
was dropped. The substring match also gave cmd quoting to any shell whose
path held "cmd".
The quoter now matches the shell's program name (no directory, no .exe,
no login dash) and names the POSIX shells it quotes for: sh, ash, bash,
dash, ksh, mksh and zsh. A shell it does not name gets a path only when
the path is a plain word every shell reads literally; any other path is
left out and a warning names it and the shell. In cmd a path holding !
is left out the same way: nothing tells the terminal whether delayed
expansion is on, and no quoting keeps ! literal both ways.
The quoter is shared, so native Desktop changes too (a custom shell or an
SSH tab). Its POSIX fallback had the same bug.
The tests read the typed text with a tokenizer for each named shell's
rules (POSIX, csh, fish, PowerShell, cmd with and without delayed
expansion).
Refs #144648 (WA-7)
Refs #144704
---
.../terminal/terminal-drop.test.ts | 233 +++++++++++++++++-
.../right-sidebar/terminal/terminal-drop.ts | 79 +++++-
apps/desktop/src/i18n/ar_chrome.ts | 1 +
apps/desktop/src/i18n/de.ts | 1 +
apps/desktop/src/i18n/en.ts | 1 +
apps/desktop/src/i18n/es.ts | 1 +
apps/desktop/src/i18n/fr.ts | 1 +
apps/desktop/src/i18n/ja.ts | 1 +
apps/desktop/src/i18n/ru.ts | 1 +
apps/desktop/src/i18n/types_right_sidebar.ts | 1 +
apps/desktop/src/i18n/zh-hant_chrome.ts | 1 +
apps/desktop/src/i18n/zh.ts | 1 +
locales/_keys.desktop.json | 1 +
13 files changed, 307 insertions(+), 16 deletions(-)
diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminal-drop.test.ts b/apps/desktop/src/app/right-sidebar/terminal/terminal-drop.test.ts
index e82fe2e28a..84d6161f23 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/terminal-drop.test.ts
+++ b/apps/desktop/src/app/right-sidebar/terminal/terminal-drop.test.ts
@@ -366,11 +366,135 @@ function fishWords(text: string): string[] {
return words
}
+// POSIX sh (and bash, zsh, ksh, dash): inside '...' every character is literal and nothing escapes
+// a quote; outside quotes a backslash escapes the next character, ';' '&' '|' end a command, '#'
+// starts a comment, and '$' or '`' starts an expansion.
+function posixWords(text: string): string[] {
+ const words: string[] = []
+ let word = ''
+ let i = 0
+
+ const flush = () => {
+ if (word) {
+ words.push(word)
+ }
+
+ word = ''
+ }
+
+ while (i < text.length) {
+ const c = text[i]
+
+ if (c === "'") {
+ const end = text.indexOf("'", i + 1)
+
+ if (end === -1) {
+ return [...words, `${word}`]
+ }
+
+ word += text.slice(i + 1, end)
+ i = end + 1
+ } else if (c === '\\') {
+ word += text[i + 1] ?? ''
+ i += 2
+ } else if (';&|'.includes(c)) {
+ flush()
+ words.push(c)
+ i += 1
+ } else if (c === '#' && !word) {
+ words.push('#comment')
+
+ break
+ } else if (c === '$' || c === '`' || c === '"') {
+ words.push('')
+ i += 1
+ } else if (/\s/.test(c)) {
+ flush()
+ i += 1
+ } else {
+ word += c
+ i += 1
+ }
+ }
+
+ flush()
+
+ return words
+}
+
+// csh and tcsh: as POSIX sh, except that '!' starts a history substitution everywhere, inside
+// single quotes too, unless a backslash escapes it (csh(1), "History substitution").
+function cshWords(text: string): string[] {
+ const words: string[] = []
+ let word = ''
+ let i = 0
+
+ const flush = () => {
+ if (word) {
+ words.push(word)
+ }
+
+ word = ''
+ }
+
+ while (i < text.length) {
+ const c = text[i]
+
+ if (c === "'") {
+ i += 1
+
+ while (i < text.length && text[i] !== "'") {
+ if (text[i] === '\\' && text[i + 1] === '!') {
+ i += 1
+ } else if (text[i] === '!') {
+ return [...words, '']
+ }
+
+ word += text[i]
+ i += 1
+ }
+
+ if (i >= text.length) {
+ return [...words, `${word}`]
+ }
+
+ i += 1
+ } else if (c === '\\') {
+ word += text[i + 1] ?? ''
+ i += 2
+ } else if (c === '!') {
+ return [...words, '']
+ } else if (';&|'.includes(c)) {
+ flush()
+ words.push(c)
+ i += 1
+ } else if (/\s/.test(c)) {
+ flush()
+ i += 1
+ } else {
+ word += c
+ i += 1
+ }
+ }
+
+ flush()
+
+ return words
+}
+
// cmd.exe at its prompt first replaces %NAME% of every defined variable, inside double quotes too,
// and leaves an undefined one as typed; then a '"' toggles quoting, '^' outside quotes takes the
-// next character literally and '&' outside quotes starts a new command.
-function cmdWords(text: string, env: Record): string[] {
- const expanded = text.replace(/%([^%]+)%/g, (whole, name: string) => env[name.toUpperCase()] ?? whole)
+// next character literally and '&' outside quotes starts a new command. With delayed expansion on
+// (cmd /V:ON, or DelayedExpansion in the registry) a line that still holds a '!' is read once more:
+// !NAME! is replaced by its value inside quotes too, a lone '!' is dropped, and '^' escapes.
+function cmdWords(text: string, env: Record, { delayed = false } = {}): string[] {
+ const percent = text.replace(/%([^%]+)%/g, (whole, name: string) => env[name.toUpperCase()] ?? whole)
+
+ const expanded =
+ delayed && percent.includes('!')
+ ? percent.replace(/!([^!]*)!|!/g, (whole, name?: string) => (name ? (env[name.toUpperCase()] ?? '') : ''))
+ : percent
+
const words: string[] = []
let word = ''
let quoted = false
@@ -472,4 +596,107 @@ describe("terminal drop types each path as one literal word of the terminal's sh
expect(droppedText(shell, "/work/it's \\ a\u2019b!.txt")).toBe("'/work/it'\\''s \\ a\u2019b!.txt' ")
}
})
+
+ const HOSTILE_POSIX_PATHS = [
+ "/work/it's; touch PWNED; '.txt",
+ '/work/$(touch PWNED) `id` $HOME.txt',
+ '/work/a\\b \\\' & | # " !!.txt',
+ '/work/plain.txt'
+ ]
+
+ it.each(['sh', 'ash', 'bash', 'dash', 'ksh', 'mksh', 'zsh', '/bin/bash', '-zsh', 'bash.exe'])(
+ 'types every path as one literal POSIX word for %s',
+ shell => {
+ for (const path of HOSTILE_POSIX_PATHS) {
+ expect(posixWords(droppedText(shell, path))).toEqual([path])
+ }
+ }
+ )
+
+ it.each(['csh', 'tcsh', '/bin/tcsh'])('types every path as one literal word with no history substitution for %s', shell => {
+ for (const path of [...HOSTILE_POSIX_PATHS, '/home/me/x!!:s^e^;touch PWNED;^.txt', '/home/me/!-2']) {
+ expect(cshWords(droppedText(shell, path))).toEqual([path])
+ }
+ })
+
+ it('matches a shell by its program name, not by a directory or a longer name', () => {
+ // A directory named like cmd must not give bash the cmd quoting.
+ expect(posixWords(droppedText('/opt/cmd-tools/bin/bash', "/work/it's.txt"))).toEqual(["/work/it's.txt"])
+ expect(droppedText('C:\\Windows\\System32\\cmd.exe', 'C:\\work\\a b.txt')).toBe('"C:\\work\\a b.txt" ')
+ expect(droppedText('C:\\Program Files\\PowerShell\\7\\pwsh.exe', "C:\\it's.txt")).toBe("'C:\\it''s.txt' ")
+ })
+})
+
+describe('terminal drop in cmd with delayed expansion', () => {
+ const env = { HERMESPROBE: 'X"&calc&"Y', PATH: 'C:\\Windows\\system32' }
+
+ it('types the same words whether delayed expansion is on or off', () => {
+ for (const path of ['C:\\work\\a%HERMESPROBE%b.txt', 'C:\\work\\a^b & c.txt', 'C:\\work\\100%', 'C:\\work\\a b (1).txt']) {
+ const typed = droppedText('cmd.exe', path)
+ expect(cmdWords(typed, env)).toEqual([path])
+ expect(cmdWords(typed, env, { delayed: true })).toEqual([path])
+ }
+ })
+
+ // cmd can't be asked whether delayed expansion is on, and no quoting keeps a '!' literal in both
+ // modes: with it on, !HERMESPROBE! is replaced inside the quotes and a lone '!' is dropped.
+ it('never types a path holding "!" and names it in a warning', () => {
+ const { host, unbind, write } = bind('cmd.exe')
+
+ drop(host, transfer([], ['C:\\work\\a!HERMESPROBE!b.txt', 'C:\\work\\wow!.txt', 'C:\\work\\ok.txt']))
+
+ expect(typedText(write)).toBe('"C:\\work\\ok.txt" ')
+ const [warning] = $notifications.get()
+ expect(warning?.kind).toBe('warning')
+ expect(warning?.message).toContain('C:\\work\\a!HERMESPROBE!b.txt')
+ expect(warning?.message).toContain('C:\\work\\wow!.txt')
+ expect(warning?.message).not.toContain('ok.txt')
+ unbind()
+ })
+})
+
+// A shell the quoter does not name (nu, xonsh, elvish, an SSH session's remote shell) has quoting
+// rules of its own, so POSIX quotes could end early there. Only a path every shell reads as one
+// plain word is typed, as it is; any other is left out and named in a warning.
+describe('terminal drop into a shell the quoter does not name', () => {
+ it.each(['nu', 'xonsh', 'elvish', 'ssh', 'C:\\tools\\nu.exe', '/usr/local/bin/xonsh'])('types only plain paths for %s', shell => {
+ const { host, unbind, write } = bind(shell)
+
+ drop(
+ host,
+ transfer([], ['/home/me/notes_v2.md', '/home/me/a b.txt', "/home/me/it's.txt", '/home/me/$HOME', '~/x', '-rf', 'C:\\x'])
+ )
+
+ expect(typedText(write)).toBe('/home/me/notes_v2.md ')
+ const [warning] = $notifications.get()
+ expect(warning?.kind).toBe('warning')
+
+ for (const path of ['/home/me/a b.txt', "/home/me/it's.txt", '/home/me/$HOME', '~/x', '-rf', 'C:\\x']) {
+ expect(warning?.message).toContain(path)
+ }
+
+ expect(warning?.message).not.toContain('notes_v2.md')
+ unbind()
+ })
+
+ it('types nothing and focuses nothing when no dropped path is plain', () => {
+ const { focus, host, unbind, write } = bind('nu')
+
+ drop(host, transfer([], ['/home/me/a b.txt']))
+
+ expect(write).not.toHaveBeenCalled()
+ expect(focus).not.toHaveBeenCalled()
+ expect($notifications.get()[0]?.message).toContain('nu')
+ unbind()
+ })
+
+ it('reads a plain path as the same one word in every named shell', () => {
+ const path = '/home/me/v1.2/notes_v2-final.md'
+
+ expect(posixWords(`${path} `)).toEqual([path])
+ expect(cshWords(`${path} `)).toEqual([path])
+ expect(fishWords(`${path} `)).toEqual([path])
+ expect(powershellWords(`${path} `)).toEqual([path])
+ expect(cmdWords(`${path} `, {}, { delayed: true })).toEqual([path])
+ })
})
diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminal-drop.ts b/apps/desktop/src/app/right-sidebar/terminal/terminal-drop.ts
index f5559131fc..bc1f18dd38 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/terminal-drop.ts
+++ b/apps/desktop/src/app/right-sidebar/terminal/terminal-drop.ts
@@ -142,19 +142,42 @@ async function stageDroppedEntries(entries: DroppedEntry[]): Promise {
return paths.filter(Boolean)
}
-// The path quoted so the terminal's shell reads it as one literal argument, whatever characters
-// the name holds: a name must never end the quoting and add a command for the next Enter. Shells
-// not named here get POSIX quoting.
-function quotePathForShell(path: string, shellName: string): string {
- const shell = shellName.toLowerCase()
+const POSIX_SHELLS = new Set(['ash', 'bash', 'dash', 'ksh', 'mksh', 'sh', 'zsh'])
- if (shell.includes('powershell') || shell.includes('pwsh')) {
+// Every shell reads a path made only of these as one plain word, so a shell the quoter does not
+// name still gets it as typed. A leading - or ~ would read as an option or a home directory.
+const PLAIN_PATH = /^[\w./:][\w./:-]*$/
+
+/** The shell's program name: `C:\...\pwsh.exe` is `pwsh`, a login shell's `-zsh` is `zsh`. */
+function shellProgram(shellName: string): string {
+ return shellName
+ .toLowerCase()
+ .replace(/^.*[\\/]/, '')
+ .replace(/^-/, '')
+ .replace(/\.exe$/, '')
+}
+
+// The path quoted so the terminal's shell reads it as one literal argument, whatever characters
+// the name holds: a name must never end the quoting and add a command for the next Enter. Null
+// when this shell has no quoting that does that: a shell not named here (nu, xonsh, elvish, the
+// remote shell of an SSH tab) gets only a plain path, typed as it is.
+function quotePathForShell(path: string, shellName: string): null | string {
+ const name = shellProgram(shellName)
+
+ if (name.startsWith('pwsh') || name.startsWith('powershell')) {
// PowerShell reads the typographic quotes U+2018..U+201B as single quotes too; doubling any of
// them keeps it literal.
return `'${path.replace(/['\u2018\u2019\u201a\u201b]/g, '$&$&')}'`
}
- if (shell.includes('cmd')) {
+ if (name === 'cmd') {
+ // With delayed expansion on (cmd /V:ON, or set in the registry) cmd replaces !NAME! inside
+ // double quotes too and drops a lone !. Nothing tells the terminal whether it is on, and no
+ // quoting keeps a ! literal both ways.
+ if (path.includes('!')) {
+ return null
+ }
+
// cmd expands %NAME% inside double quotes before it reads them, and a value holding a quote
// would end the quoting. Each % goes outside the quotes as ^%, which no variable name matches.
return path
@@ -163,8 +186,6 @@ function quotePathForShell(path: string, shellName: string): string {
.join('^%')
}
- const name = shell.replace(/^.*[\\/]/, '').replace(/\.exe$/, '')
-
if (name === 'fish') {
// Inside fish's single quotes \' and \\ are escapes.
return `'${path.replace(/[\\']/g, '\\$&')}'`
@@ -172,8 +193,16 @@ function quotePathForShell(path: string, shellName: string): string {
const quoted = `'${path.replace(/'/g, "'\\''")}'`
- // csh and tcsh expand !-history even inside single quotes; \! is the escape there.
- return name === 'csh' || name === 'tcsh' ? quoted.replace(/!/g, '\\!') : quoted
+ if (name === 'csh' || name === 'tcsh') {
+ // csh and tcsh expand !-history even inside single quotes; \! is the escape there.
+ return quoted.replace(/!/g, '\\!')
+ }
+
+ if (POSIX_SHELLS.has(name)) {
+ return quoted
+ }
+
+ return PLAIN_PATH.test(path) ? path : null
}
// C0 controls, DEL and C1 controls. A line editor acts on them inside any quoting (^U erases the
@@ -233,7 +262,23 @@ export function bindTerminalDrop(
const typePaths = (paths: string[]) => {
const refused = paths.filter(hasControlCharacter)
- const typed = paths.filter(path => !hasControlCharacter(path))
+ const shellName = getShellName()
+ const typed: string[] = []
+ const unquotable: string[] = []
+
+ for (const path of paths) {
+ if (hasControlCharacter(path)) {
+ continue
+ }
+
+ const quoted = quotePathForShell(path, shellName)
+
+ if (quoted === null) {
+ unquotable.push(path)
+ } else {
+ typed.push(quoted)
+ }
+ }
if (refused.length) {
notify({
@@ -243,12 +288,20 @@ export function bindTerminalDrop(
})
}
+ if (unquotable.length) {
+ notify({
+ kind: 'warning',
+ title: translateNow('rightSidebar.terminalDropControlTitle'),
+ message: translateNow('rightSidebar.terminalDropUnquotable', shellProgram(shellName), unquotable.join(', '))
+ })
+ }
+
if (!typed.length) {
return
}
markActivity()
- term.paste(`${typed.map(p => quotePathForShell(p, getShellName())).join(' ')} `)
+ term.paste(`${typed.join(' ')} `)
term.focus()
triggerHaptic('selection')
}
diff --git a/apps/desktop/src/i18n/ar_chrome.ts b/apps/desktop/src/i18n/ar_chrome.ts
index 0e28a140ee..32af052ebc 100644
--- a/apps/desktop/src/i18n/ar_chrome.ts
+++ b/apps/desktop/src/i18n/ar_chrome.ts
@@ -475,6 +475,7 @@ export const arChrome = {
}
},
rightSidebar: {
+ terminalDropUnquotable: (shell, paths) => `لا يمكن كتابة هذه الأسماء بأمان في ${shell}: ${paths}`,
terminalReadOnly: 'مخرجات للقراءة فقط',
terminalReadOnlyHelp:
'للرد على المطالبات، أوقف الأمر الذي يعمل في الخلفية وشغّله في طرفية جديدة. تفتح الطرفية الجديدة صدفة منفصلة ولا تتصل بهذه العملية.',
diff --git a/apps/desktop/src/i18n/de.ts b/apps/desktop/src/i18n/de.ts
index d366f8701d..5520b71483 100644
--- a/apps/desktop/src/i18n/de.ts
+++ b/apps/desktop/src/i18n/de.ts
@@ -4616,6 +4616,7 @@ export const deOverrides = {
}
},
rightSidebar: {
+ terminalDropUnquotable: (shell, paths) => `Diese Namen lassen sich in ${shell} nicht sicher eintippen: ${paths}`,
aria: 'Rechte Sidebar',
panelsAria: 'Panels der rechten Sidebar',
files: 'Dateisystem',
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index abaf846b37..6da10ac5b1 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -4234,6 +4234,7 @@ export const en: Translations = {
terminalDropTooLarge: (names, limit) => `Each file can be up to ${limit}. Not uploaded: ${names}`,
terminalDropControlTitle: 'Not typed into the terminal',
terminalDropControl: paths => `These names hold control characters the shell would act on as keys: ${paths}`,
+ terminalDropUnquotable: (shell, paths) => `No safe way to type these names in ${shell}: ${paths}`,
addToChat: 'Add to chat'
},
diff --git a/apps/desktop/src/i18n/es.ts b/apps/desktop/src/i18n/es.ts
index 214fe509e8..c31277768a 100644
--- a/apps/desktop/src/i18n/es.ts
+++ b/apps/desktop/src/i18n/es.ts
@@ -4600,6 +4600,7 @@ export const esOverrides = {
}
},
rightSidebar: {
+ terminalDropUnquotable: (shell, paths) => `Estos nombres no se pueden escribir de forma segura en ${shell}: ${paths}`,
aria: 'Barra lateral derecha',
panelsAria: 'Paneles de la barra lateral derecha',
files: 'Sistema de archivos',
diff --git a/apps/desktop/src/i18n/fr.ts b/apps/desktop/src/i18n/fr.ts
index c7234f2a21..c99e4b0931 100644
--- a/apps/desktop/src/i18n/fr.ts
+++ b/apps/desktop/src/i18n/fr.ts
@@ -4624,6 +4624,7 @@ export const frOverrides = {
}
},
rightSidebar: {
+ terminalDropUnquotable: (shell, paths) => `Impossible de saisir ces noms en toute sécurité dans ${shell} : ${paths}`,
aria: 'Barre latérale droite',
panelsAria: 'Panneaux de la barre latérale droite',
files: 'Système de fichiers',
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 656c08d6b4..934661e6e5 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -3028,6 +3028,7 @@ export const jaOverrides = {
},
rightSidebar: {
+ terminalDropUnquotable: (shell, paths) => `これらの名前は ${shell} に安全に入力できません: ${paths}`,
terminalReadOnly: '読み取り専用の出力',
terminalReadOnlyHelp:
'プロンプトに応答するには、バックグラウンドのコマンドを停止し、新しいターミナルで実行してください。新しいターミナルは別のシェルを開き、このプロセスには接続しません。',
diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts
index 1fa8127263..56e66486e9 100644
--- a/apps/desktop/src/i18n/ru.ts
+++ b/apps/desktop/src/i18n/ru.ts
@@ -3283,6 +3283,7 @@ export const ruOverrides = {
}
},
rightSidebar: {
+ terminalDropUnquotable: (shell, paths) => `Эти имена нельзя безопасно ввести в ${shell}: ${paths}`,
terminalReadOnly: 'Вывод только для чтения',
terminalReadOnlyHelp:
'Чтобы ответить на запрос, остановите фоновую команду и запустите её в новом терминале. Он откроет отдельную оболочку и не подключится к этому процессу.',
diff --git a/apps/desktop/src/i18n/types_right_sidebar.ts b/apps/desktop/src/i18n/types_right_sidebar.ts
index f7e5440bc6..083901ea1d 100644
--- a/apps/desktop/src/i18n/types_right_sidebar.ts
+++ b/apps/desktop/src/i18n/types_right_sidebar.ts
@@ -48,5 +48,6 @@ export interface RightSidebarTranslations {
terminalDropTooLarge: (names: string, limit: string) => string
terminalDropControlTitle: string
terminalDropControl: (paths: string) => string
+ terminalDropUnquotable: (shell: string, paths: string) => string
addToChat: string
}
diff --git a/apps/desktop/src/i18n/zh-hant_chrome.ts b/apps/desktop/src/i18n/zh-hant_chrome.ts
index 682771d20b..07a0a9b3ec 100644
--- a/apps/desktop/src/i18n/zh-hant_chrome.ts
+++ b/apps/desktop/src/i18n/zh-hant_chrome.ts
@@ -421,6 +421,7 @@ export const zhHantChrome = {
},
rightSidebar: {
+ terminalDropUnquotable: (shell, paths) => `無法在 ${shell} 中安全地輸入這些名稱:${paths}`,
terminalReadOnly: '唯讀輸出',
terminalReadOnlyHelp:
'如需回應提示,請停止背景命令,再於新終端機中執行。新終端機會開啟獨立的 shell,不會連線至此程序。',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 3ae27ad989..a69d95df46 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -4030,6 +4030,7 @@ export const zhOverrides = {
},
rightSidebar: {
+ terminalDropUnquotable: (shell, paths) => `无法在 ${shell} 中安全地输入这些名称:${paths}`,
terminalReadOnly: '只读输出',
terminalReadOnlyHelp:
'如需回应提示,请停止后台命令,再在新终端中运行。新终端会打开独立的 shell,不会连接到此进程。',
diff --git a/locales/_keys.desktop.json b/locales/_keys.desktop.json
index 32c0634695..a4e286b542 100644
--- a/locales/_keys.desktop.json
+++ b/locales/_keys.desktop.json
@@ -2688,6 +2688,7 @@
"rightSidebar.terminalDropControlTitle",
"rightSidebar.terminalDropTooLarge",
"rightSidebar.terminalDropTooLargeTitle",
+ "rightSidebar.terminalDropUnquotable",
"rightSidebar.terminalDropUploadFailed",
"rightSidebar.terminalHide",
"rightSidebar.terminalNew",
From e843e0427f1dd7f09f8cb8209b4c0c3ceb1c6108 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Sun, 25 Oct 2026 23:21:27 +0000
Subject: [PATCH 068/114] fix(webapp): hold the redraw's step back until the
program answered the step
After a trimmed replay the browser sends a resize one row away and then
the real size, so the foreground program redraws. The host terminal pump
applied both at once, and a program that reads the size late can handle
both SIGWINCHes after the size is back where it was. Node reports a
stdout 'resize' only when the size it reads has changed, and Hermes' TUI
redraws on that event, so nothing redrew: on a real PTY a Node 26 child
got the signals and reported no resize.
A resize back to the size before the previous one now waits until the
program has written output since that one (a program that redraws on a
resize does), or one second for a program that does not. Other resizes,
including a drag's, are applied at once as before. The POSIX bridge reads
the PTY size with TIOCGWINSZ; a bridge that cannot report it (ConPTY)
never waits.
Known limit: output the program wrote before the step but read after it
counts as an answer, so a program printing continuously can still miss
the step.
---
apps/desktop/src/lib/browser-terminal.ts | 3 +-
hermes_cli/pty_bridge.py | 11 ++
hermes_cli/pty_session.py | 29 ++++
hermes_cli/web_host_terminal_sessions.py | 23 ++-
tests/hermes_cli/test_host_terminal_redraw.py | 143 ++++++++++++++++++
5 files changed, 207 insertions(+), 2 deletions(-)
create mode 100644 tests/hermes_cli/test_host_terminal_redraw.py
diff --git a/apps/desktop/src/lib/browser-terminal.ts b/apps/desktop/src/lib/browser-terminal.ts
index ecf437a55b..040dba8747 100644
--- a/apps/desktop/src/lib/browser-terminal.ts
+++ b/apps/desktop/src/lib/browser-terminal.ts
@@ -72,7 +72,8 @@ function settleOpen(state: TerminalState): void {
}
/** A trimmed replay can start mid-way through a full-screen program's output, and an
- * unchanged size sends no SIGWINCH: step one row away first so the program redraws. */
+ * unchanged size sends no SIGWINCH: step one row away first so the program redraws. The server
+ * holds the step back until the program has answered the step (web_host_terminal_sessions._resize). */
function sendResumeSize(socket: WebSocket, state: TerminalState, trimmed?: boolean): void {
if (trimmed) {socket.send(`\u001b[RESIZE:${state.cols};${state.rows > 1 ? state.rows - 1 : state.rows + 1}]`)}
socket.send(`\u001b[RESIZE:${state.cols};${state.rows}]`)
diff --git a/hermes_cli/pty_bridge.py b/hermes_cli/pty_bridge.py
index 80bd8c826f..8221afa3d7 100644
--- a/hermes_cli/pty_bridge.py
+++ b/hermes_cli/pty_bridge.py
@@ -302,6 +302,17 @@ class PtyBridge:
except OSError:
pass
+ def size(self) -> Optional[tuple[int, int]]:
+ """The child's ``(cols, rows)`` (``TIOCGWINSZ``), or None once the PTY is closed."""
+ if self._closed:
+ return None
+ try:
+ rows, cols, _xpixel, _ypixel = struct.unpack(
+ "HHHH", fcntl.ioctl(self._fd, termios.TIOCGWINSZ, bytes(8)))
+ except OSError:
+ return None
+ return cols, rows
+
def _discard_output(self, timeout: float) -> None:
# Exiting children flush their pending terminal output first; on macOS a process stays in
# exit (and a SIGHUP save outlasts the grace) until someone drains the master. The drain
diff --git a/hermes_cli/pty_session.py b/hermes_cli/pty_session.py
index 3d6d1aa7f2..dbd22186e5 100644
--- a/hermes_cli/pty_session.py
+++ b/hermes_cli/pty_session.py
@@ -127,6 +127,9 @@ class PtySession:
self._output_lock = asyncio.Lock()
self._input_task: Optional[asyncio.Task] = None
self._close_task: Optional[asyncio.Task] = None
+ # Chunks of output read so far, and set when one arrives (``wait_for_output``).
+ self._outputs = 0
+ self._output_arrived = asyncio.Event()
async def start(self) -> None:
self._drain_task = asyncio.create_task(self._drain())
@@ -149,6 +152,8 @@ class PtySession:
await asyncio.sleep(0)
continue
self.buffer.append(chunk)
+ self._outputs += 1
+ self._output_arrived.set()
ws = self._ws
output_lock = self._output_lock
try:
@@ -211,6 +216,30 @@ class PtySession:
if self._ws is ws and self.alive:
self.bridge.resize(cols=cols, rows=rows)
+ def size(self) -> Optional[tuple[int, int]]:
+ """The PTY's ``(cols, rows)``, or None where the bridge cannot tell (ConPTY, stubs)."""
+ return self.bridge.size() if hasattr(self.bridge, "size") else None
+
+ @property
+ def outputs(self) -> int:
+ """How many chunks of output have been read from the program so far."""
+ return self._outputs
+
+ async def wait_for_output(self, since: int, timeout: float) -> None:
+ """Return once output past ``since`` (an ``outputs`` value) has been read, the session has
+ ended, or ``timeout`` seconds have passed."""
+ loop = asyncio.get_running_loop()
+ deadline = loop.time() + timeout
+ while self.alive and self._outputs == since:
+ self._output_arrived.clear()
+ remaining = deadline - loop.time()
+ if remaining <= 0:
+ return
+ try:
+ await asyncio.wait_for(self._output_arrived.wait(), remaining)
+ except TimeoutError:
+ return
+
async def attach(
self, ws, *, force_redraw: bool = False,
initial_text: Optional[str | Callable[[bool], str]] = None,
diff --git a/hermes_cli/web_host_terminal_sessions.py b/hermes_cli/web_host_terminal_sessions.py
index 61f2d110b9..ca303501cd 100644
--- a/hermes_cli/web_host_terminal_sessions.py
+++ b/hermes_cli/web_host_terminal_sessions.py
@@ -31,6 +31,8 @@ RETENTION_SECONDS = 15 * 60
_MAX_SHELLS = 16
# Inbound frames buffered while one batch is written (backpressure bound).
_INPUT_BATCH_FRAMES = 64
+# How long a resize that undoes the previous one waits for the program to answer that one (``_resize``).
+_RESIZE_ANSWER_SECONDS = 1.0
TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{43}$")
@@ -333,6 +335,24 @@ def _input_runs(frames: list[dict], resize_re):
yield run
+async def _resize(session: PtySession, ws: WebSocket, size: tuple[int, int], undo):
+ """Apply ``size``; return what the next resize needs to tell whether it undoes this one.
+
+ A program can read the size well after its SIGWINCH (Node, behind its event loop, and reports a
+ 'resize', which Hermes' TUI redraws on, only when the size it reads has changed). A step undone
+ before it looks is never seen: both signals find the size it already had. So a resize back to
+ the size before the previous one first waits until the program has written output since that
+ one, as a program that redraws on a resize does, or ``_RESIZE_ANSWER_SECONDS`` for one that
+ does not. ``undo`` is ``(size before the previous resize, outputs when it was applied)``, set
+ only when that resize changed the size.
+ """
+ if undo is not None and undo[0] == size:
+ await session.wait_for_output(undo[1], _RESIZE_ANSWER_SECONDS)
+ before = session.size()
+ session.resize(ws, cols=size[0], rows=size[1])
+ return (before, session.outputs) if before is not None and before != size else None
+
+
async def _pump_input(ws: WebSocket, registry: HostTerminalRegistry, token: str,
session: PtySession, owner: HostOwner) -> None:
"""Forward keystrokes and resizes while ``ws`` is the attached viewer.
@@ -355,6 +375,7 @@ async def _pump_input(ws: WebSocket, registry: HostTerminalRegistry, token: str,
inbox: asyncio.Queue = asyncio.Queue(maxsize=_INPUT_BATCH_FRAMES)
ended = asyncio.get_running_loop().create_future()
reader = asyncio.create_task(_read_frames(ws, inbox, ended))
+ undo = None
try:
# No Ctrl-L/redraw input: arbitrary foreground shell programs own stdin.
while session._ws is ws and session.alive:
@@ -364,7 +385,7 @@ async def _pump_input(ws: WebSocket, registry: HostTerminalRegistry, token: str,
frames = [msg for msg in batch if isinstance(msg, dict)]
for item in _input_runs(frames, _RESIZE_RE):
if isinstance(item, tuple):
- session.resize(ws, cols=item[0], rows=item[1])
+ undo = await _resize(session, ws, item, undo)
continue
try:
delivered = await _write_while_open(session, ws, item, owner.admit, ended)
diff --git a/tests/hermes_cli/test_host_terminal_redraw.py b/tests/hermes_cli/test_host_terminal_redraw.py
new file mode 100644
index 0000000000..53d45fc371
--- /dev/null
+++ b/tests/hermes_cli/test_host_terminal_redraw.py
@@ -0,0 +1,143 @@
+"""The resize pair that redraws a host terminal's program reaches it as two observed sizes."""
+import asyncio
+import shutil
+import sys
+from types import SimpleNamespace
+
+import pytest
+
+from hermes_cli import web_host_terminal_sessions
+from hermes_cli.pty_session import PtySession
+from hermes_cli.web_host_terminal_sessions import HostOwner, _pump_input, _resize
+
+# Programs that, like Node's stdout 'resize' event (Hermes' TUI redraws on it), report a size only
+# when the one they read differs from the last, and read it after a busy event loop gets to it.
+_PYTHON_SIZE_WATCHER = """
+import os, signal, time
+pending = []
+signal.signal(signal.SIGWINCH, lambda *_: pending.append(1))
+last = os.get_terminal_size(1)
+print('READY', flush=True)
+while True:
+ time.sleep(0.2)
+ if pending:
+ pending.clear()
+ size = os.get_terminal_size(1)
+ if size != last:
+ last = size
+ print('RESIZE', size.lines, flush=True)
+ print('WINCH', flush=True)
+ print('TICK', flush=True)
+"""
+_NODE_SIZE_WATCHER = (
+ "process.stdout.on('resize', () => process.stdout.write('RESIZE ' + process.stdout.rows + '\\n'));"
+ "process.on('SIGWINCH', () => process.stdout.write('WINCH\\n'));"
+ "process.stdout.write('READY\\n'); setInterval(() => process.stdout.write('TICK\\n'), 200)"
+)
+
+
+class Tab:
+ """The attached browser tab: it keeps what the program prints and sends ``send``'s frames."""
+
+ query_params: dict = {}
+
+ def __init__(self):
+ self.output = b""
+ self.inbound = asyncio.Queue()
+
+ def send(self, *frames):
+ for frame in frames:
+ self.inbound.put_nowait({"type": "websocket.receive", "text": frame})
+
+ def close_tab(self):
+ self.inbound.put_nowait({"type": "websocket.disconnect"})
+
+ async def receive(self):
+ return await self.inbound.get()
+
+ async def send_text(self, _text):
+ pass
+
+ async def send_bytes(self, data):
+ self.output += data
+
+ async def close(self, code=1000, reason=None):
+ pass
+
+
+@pytest.mark.platforms("posix")
+@pytest.mark.asyncio
+@pytest.mark.parametrize("answered", [True, False], ids=["answered", "unanswered"])
+@pytest.mark.parametrize("watcher", ["python", "node"])
+async def test_a_resize_back_waits_until_the_program_answered_the_step(tmp_path, monkeypatch, watcher, answered):
+ """After a trimmed replay the browser steps the size one row away and back (browser-terminal.ts)
+ so the foreground program redraws. Applied back to back, both SIGWINCHes can reach the program
+ after the size is back where it was: it sees no change and does not redraw. The step back waits
+ until the program has answered the first step; without that wait (``unanswered``) the signals
+ arrive but neither step is seen."""
+ from hermes_cli.pty_bridge import PtyBridge
+
+ if watcher == "node":
+ node = shutil.which("node")
+ if node is None:
+ pytest.skip("node is not installed")
+ argv = [node, "-e", _NODE_SIZE_WATCHER]
+ else:
+ argv = [sys.executable, "-u", "-c", _PYTHON_SIZE_WATCHER]
+ if not answered:
+ monkeypatch.setattr(web_host_terminal_sessions, "_RESIZE_ANSWER_SECONDS", 0, raising=False)
+
+ async def remove(_token):
+ pass
+
+ bridge = PtyBridge.spawn(argv, cwd=str(tmp_path), cols=80, rows=24)
+ session = PtySession("terminal", bridge, buffer_cap=1 << 16, read_timeout=0.01)
+ owner = HostOwner(("loopback", "test"), tmp_path, "sh", str(tmp_path))
+ tab = Tab()
+ pump = None
+ await session.start()
+ try:
+ assert await session.attach(tab)
+ async with asyncio.timeout(10):
+ while b"READY" not in tab.output:
+ await asyncio.sleep(0.01)
+ start = len(tab.output)
+ # The redraw's two frames, arriving together as they leave the browser.
+ tab.send("\x1b[RESIZE:80;23]", "\x1b[RESIZE:80;24]")
+ pump = asyncio.create_task(_pump_input(tab, SimpleNamespace(remove=remove), "terminal", session, owner))
+ # The program prints TICK five times a second: give it three seconds.
+ async with asyncio.timeout(10):
+ while b"RESIZE 24" not in tab.output[start:] and tab.output[start:].count(b"TICK") < 15:
+ await asyncio.sleep(0.01)
+ seen = tab.output[start:]
+ tab.close_tab()
+ await asyncio.wait_for(pump, 5)
+ finally:
+ if pump is not None:
+ pump.cancel()
+ await session.close()
+ await asyncio.to_thread(bridge.close)
+ assert b"WINCH" in seen
+ reported = [line for line in seen.split(b"\r\n") if line.startswith(b"RESIZE ")]
+ assert reported == ([b"RESIZE 23", b"RESIZE 24"] if answered else [])
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("knows_size", [True, False], ids=["posix", "conpty"])
+async def test_only_a_resize_that_undoes_the_previous_one_waits(knows_size):
+ """A drag's resizes are applied at once; so are repeats of the same size and every resize on a
+ bridge that cannot report its size."""
+ current = [(80, 24)]
+ waits = []
+
+ async def wait_for_output(since, timeout):
+ waits.append((since, timeout))
+
+ session = SimpleNamespace(
+ size=lambda: current[0] if knows_size else None, outputs=7, wait_for_output=wait_for_output,
+ resize=lambda _ws, *, cols, rows: current.__setitem__(0, (cols, rows)))
+ undo = None
+ for step in [(80, 23), (80, 24), (100, 30), (100, 30), (80, 24), (90, 24)]:
+ undo = await _resize(session, None, step, undo)
+ assert current[0] == step
+ assert waits == ([(7, web_host_terminal_sessions._RESIZE_ANSWER_SECONDS)] if knows_size else [])
From e9db3e08953eb76cd410dcfebc9afd5aed426362 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Sun, 25 Oct 2026 23:22:53 +0000
Subject: [PATCH 069/114] test(webapp): wait for the redraw test's output by
condition, not a sleep loop
The PTY redraw test polled with asyncio.sleep, which the code health check
rejects (HX013). It now waits with tests.waiting.async_wait_until.
---
tests/hermes_cli/test_host_terminal_redraw.py | 10 ++++------
1 file changed, 4 insertions(+), 6 deletions(-)
diff --git a/tests/hermes_cli/test_host_terminal_redraw.py b/tests/hermes_cli/test_host_terminal_redraw.py
index 53d45fc371..df79be1295 100644
--- a/tests/hermes_cli/test_host_terminal_redraw.py
+++ b/tests/hermes_cli/test_host_terminal_redraw.py
@@ -9,6 +9,7 @@ import pytest
from hermes_cli import web_host_terminal_sessions
from hermes_cli.pty_session import PtySession
from hermes_cli.web_host_terminal_sessions import HostOwner, _pump_input, _resize
+from tests.waiting import async_wait_until
# Programs that, like Node's stdout 'resize' event (Hermes' TUI redraws on it), report a size only
# when the one they read differs from the last, and read it after a busy event loop gets to it.
@@ -98,17 +99,14 @@ async def test_a_resize_back_waits_until_the_program_answered_the_step(tmp_path,
await session.start()
try:
assert await session.attach(tab)
- async with asyncio.timeout(10):
- while b"READY" not in tab.output:
- await asyncio.sleep(0.01)
+ await async_wait_until(lambda: b"READY" in tab.output, timeout=10)
start = len(tab.output)
# The redraw's two frames, arriving together as they leave the browser.
tab.send("\x1b[RESIZE:80;23]", "\x1b[RESIZE:80;24]")
pump = asyncio.create_task(_pump_input(tab, SimpleNamespace(remove=remove), "terminal", session, owner))
# The program prints TICK five times a second: give it three seconds.
- async with asyncio.timeout(10):
- while b"RESIZE 24" not in tab.output[start:] and tab.output[start:].count(b"TICK") < 15:
- await asyncio.sleep(0.01)
+ await async_wait_until(
+ lambda: b"RESIZE 24" in tab.output[start:] or tab.output[start:].count(b"TICK") >= 15, timeout=10)
seen = tab.output[start:]
tab.close_tab()
await asyncio.wait_for(pump, 5)
From f0d5ab3da42d7d02d2b48a4deb9f9bdf86ea6eca Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 01:05:40 +0000
Subject: [PATCH 070/114] test(webapp): move the PTY close-reason tests into
their own module
The three close-reason tests took test_pty_session.py past 1,000 lines.
They are a topic of their own, so they now live in
test_pty_session_close_reason.py and import FakeBridge and FakeWS from
test_pty_session.py. The tests themselves are unchanged.
---
tests/hermes_cli/test_pty_session.py | 40 -----------------
.../test_pty_session_close_reason.py | 44 +++++++++++++++++++
2 files changed, 44 insertions(+), 40 deletions(-)
create mode 100644 tests/hermes_cli/test_pty_session_close_reason.py
diff --git a/tests/hermes_cli/test_pty_session.py b/tests/hermes_cli/test_pty_session.py
index a2bd810bcd..58812b3348 100644
--- a/tests/hermes_cli/test_pty_session.py
+++ b/tests/hermes_cli/test_pty_session.py
@@ -521,46 +521,6 @@ async def test_eof_marks_dead_and_closes_socket_4410():
await s.close()
-# 4410 also ends a shell the server closed (expiry, a deleted profile, shutdown). Only a shell that
-# exited by itself says so in the close reason, so a viewer can end its tab the way native Desktop
-# does when its shell exits, and keep the tab for the other cases.
-@pytest.mark.asyncio
-async def test_eof_close_says_the_process_exited():
- from hermes_cli.pty_session import PtySession
- s = PtySession("k", FakeBridge([b"logout", None]), buffer_cap=1024, read_timeout=0.01)
- await s.start()
- ws = FakeWS()
- await s.attach(ws)
- await async_wait_until(lambda: ws.close_code is not None, timeout=2.0)
- assert (ws.close_code, ws.close_reason) == (4410, "Process exited")
- await s.close()
-
-
-@pytest.mark.asyncio
-async def test_attaching_to_a_shell_that_exited_while_detached_says_it_exited():
- from hermes_cli.pty_session import PtySession
- s = PtySession("k", FakeBridge([b"logout", None]), buffer_cap=1024, read_timeout=0.01)
- await s.start()
- await async_wait_until(lambda: not s.alive, timeout=2.0)
- ws = FakeWS()
- assert await s.attach(ws, initial_text="meta") is False
- assert ws.sent == [("text", "meta"), ("bytes", b"logout")]
- assert (ws.close_code, ws.close_reason) == (4410, "Process exited")
- await s.close()
-
-
-@pytest.mark.asyncio
-async def test_a_server_close_keeps_4410_without_the_exit_reason():
- from hermes_cli.pty_session import PtySession
- s = PtySession("k", FakeBridge([]), buffer_cap=1024, read_timeout=0.01)
- await s.start()
- ws = FakeWS()
- await s.attach(ws)
- await s.close()
- assert ws.close_code == 4410
- assert ws.close_reason != "Process exited"
-
-
from hermes_cli.pty_session import PtySessionRegistry, RegistryFull
diff --git a/tests/hermes_cli/test_pty_session_close_reason.py b/tests/hermes_cli/test_pty_session_close_reason.py
new file mode 100644
index 0000000000..d700d93565
--- /dev/null
+++ b/tests/hermes_cli/test_pty_session_close_reason.py
@@ -0,0 +1,44 @@
+"""4410 also ends a shell the server closed (expiry, a deleted profile, shutdown). Only a shell that
+exited by itself says so in the close reason, so a viewer can end its tab the way native Desktop
+does when its shell exits, and keep the tab for the other cases."""
+import pytest
+
+from tests.hermes_cli.test_pty_session import FakeBridge, FakeWS
+from tests.waiting import async_wait_until
+
+
+@pytest.mark.asyncio
+async def test_eof_close_says_the_process_exited():
+ from hermes_cli.pty_session import PtySession
+ s = PtySession("k", FakeBridge([b"logout", None]), buffer_cap=1024, read_timeout=0.01)
+ await s.start()
+ ws = FakeWS()
+ await s.attach(ws)
+ await async_wait_until(lambda: ws.close_code is not None, timeout=2.0)
+ assert (ws.close_code, ws.close_reason) == (4410, "Process exited")
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_attaching_to_a_shell_that_exited_while_detached_says_it_exited():
+ from hermes_cli.pty_session import PtySession
+ s = PtySession("k", FakeBridge([b"logout", None]), buffer_cap=1024, read_timeout=0.01)
+ await s.start()
+ await async_wait_until(lambda: not s.alive, timeout=2.0)
+ ws = FakeWS()
+ assert await s.attach(ws, initial_text="meta") is False
+ assert ws.sent == [("text", "meta"), ("bytes", b"logout")]
+ assert (ws.close_code, ws.close_reason) == (4410, "Process exited")
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_a_server_close_keeps_4410_without_the_exit_reason():
+ from hermes_cli.pty_session import PtySession
+ s = PtySession("k", FakeBridge([]), buffer_cap=1024, read_timeout=0.01)
+ await s.start()
+ ws = FakeWS()
+ await s.attach(ws)
+ await s.close()
+ assert ws.close_code == 4410
+ assert ws.close_reason != "Process exited"
From bd7b309522bb8ec5407134244016dc86cc0c2e5f Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 01:07:01 +0000
Subject: [PATCH 071/114] test(webapp): check the redraw's held-back step
without a live race
The no-wait case of the redraw test set the wait to zero and required a
real program to miss the intermediate size. A program that runs between
the two size changes sees both and reports them correctly, so that case
could fail on a busy runner.
The real-PTY test now checks only that Python and Node watchers see both
sizes. A new test drives _resize over a scripted bridge that prints only
when told to. The step back stays unapplied while nothing is printed,
output read before the step does not release it, and output after the
step does.
---
tests/hermes_cli/test_host_terminal_redraw.py | 63 ++++++++++++++++---
1 file changed, 56 insertions(+), 7 deletions(-)
diff --git a/tests/hermes_cli/test_host_terminal_redraw.py b/tests/hermes_cli/test_host_terminal_redraw.py
index df79be1295..967112468d 100644
--- a/tests/hermes_cli/test_host_terminal_redraw.py
+++ b/tests/hermes_cli/test_host_terminal_redraw.py
@@ -1,5 +1,6 @@
"""The resize pair that redraws a host terminal's program reaches it as two observed sizes."""
import asyncio
+import queue
import shutil
import sys
from types import SimpleNamespace
@@ -68,14 +69,12 @@ class Tab:
@pytest.mark.platforms("posix")
@pytest.mark.asyncio
-@pytest.mark.parametrize("answered", [True, False], ids=["answered", "unanswered"])
@pytest.mark.parametrize("watcher", ["python", "node"])
-async def test_a_resize_back_waits_until_the_program_answered_the_step(tmp_path, monkeypatch, watcher, answered):
+async def test_a_resize_back_waits_until_the_program_answered_the_step(tmp_path, watcher):
"""After a trimmed replay the browser steps the size one row away and back (browser-terminal.ts)
so the foreground program redraws. Applied back to back, both SIGWINCHes can reach the program
after the size is back where it was: it sees no change and does not redraw. The step back waits
- until the program has answered the first step; without that wait (``unanswered``) the signals
- arrive but neither step is seen."""
+ until the program has answered the first step, so a real program sees both sizes."""
from hermes_cli.pty_bridge import PtyBridge
if watcher == "node":
@@ -85,8 +84,6 @@ async def test_a_resize_back_waits_until_the_program_answered_the_step(tmp_path,
argv = [node, "-e", _NODE_SIZE_WATCHER]
else:
argv = [sys.executable, "-u", "-c", _PYTHON_SIZE_WATCHER]
- if not answered:
- monkeypatch.setattr(web_host_terminal_sessions, "_RESIZE_ANSWER_SECONDS", 0, raising=False)
async def remove(_token):
pass
@@ -117,7 +114,59 @@ async def test_a_resize_back_waits_until_the_program_answered_the_step(tmp_path,
await asyncio.to_thread(bridge.close)
assert b"WINCH" in seen
reported = [line for line in seen.split(b"\r\n") if line.startswith(b"RESIZE ")]
- assert reported == ([b"RESIZE 23", b"RESIZE 24"] if answered else [])
+ assert reported == [b"RESIZE 23", b"RESIZE 24"]
+
+
+class ScriptedBridge:
+ """A PTY whose program prints only when the test says so; it records every size set on it."""
+
+ def __init__(self):
+ self.sizes = [(80, 24)]
+ self.printed = queue.SimpleQueue()
+
+ def read(self, timeout):
+ try:
+ return self.printed.get(timeout=timeout)
+ except queue.Empty:
+ return b""
+
+ def size(self):
+ return self.sizes[-1]
+
+ def resize(self, cols, rows):
+ self.sizes.append((cols, rows))
+
+ def close(self):
+ self.printed.put(None)
+
+ def is_alive(self):
+ return True
+
+
+@pytest.mark.asyncio
+async def test_the_step_back_is_applied_only_after_output_that_follows_the_step(monkeypatch):
+ """Whatever the scheduler does, the size goes back only once the program has printed
+ something after the step was applied; output read before the step does not count."""
+ monkeypatch.setattr(web_host_terminal_sessions, "_RESIZE_ANSWER_SECONDS", 60)
+ bridge = ScriptedBridge()
+ session = PtySession("terminal", bridge, buffer_cap=1 << 16, read_timeout=0.01)
+ tab = Tab()
+ await session.start()
+ try:
+ assert await session.attach(tab)
+ bridge.printed.put(b"before the step")
+ await async_wait_until(lambda: b"before the step" in tab.output, timeout=10)
+ undo = await _resize(session, tab, (80, 23), None)
+ back = asyncio.create_task(_resize(session, tab, (80, 24), undo))
+ # Nothing is printed, so the step back cannot be applied however long this waits.
+ done, _ = await asyncio.wait({back}, timeout=0.2)
+ assert not done
+ assert bridge.sizes == [(80, 24), (80, 23)]
+ bridge.printed.put(b"redrawn at 23 rows")
+ await asyncio.wait_for(back, 10)
+ assert bridge.sizes == [(80, 24), (80, 23), (80, 24)]
+ finally:
+ await session.close()
@pytest.mark.asyncio
From 19c0771494650ed7bfe414e01b2d1f2affc17201 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 01:11:13 +0000
Subject: [PATCH 072/114] fix(webapp): close the tab of a host shell that
exited while it was away
A shell that exited while no tab was attached still left an expired
tab. On redial HostTerminalRegistry.resolve refused the dead session
before PtySession.attach could send the exit reason, and once the reaper
had removed the session the route only knew the token was gone. Both
cases closed with the generic expiry reason, which keeps the tab and
offers a new shell.
The registry now raises TerminalExited (a TerminalExpired) for a dead
session that is still registered. The server always removes a session
before closing it, so only the shell's own exit leaves a dead one
behind. When the reaper takes a shell that exited, the registry
remembers its token and owner identity for the retention TTL, at most
64 of them, and a redial by that identity also gets TerminalExited.
The route closes it with 4410 and 'Process exited', the close an
attached tab gets. Deleted profiles, expiry and explicit closes keep
the generic reason.
The browser tab reads that close before the handshake too. A redial
reports a process exit, and resuming a saved shell (a remount or a
page reload) rejects with the 'exited' signal, which closes the tab.
_admit, _read_frames, the constants block and the import block are not
touched.
The new route test runs a real shell that exits after its tab has
detached, before and after the reaper runs. At aba0914d8c96 both cases
fail with 'Terminal or profile expired; open a new terminal'.
---
.../terminal/terminal-session-attempt.ts | 7 ++-
.../terminal/use-terminal-session.test.tsx | 26 +++++++++++
apps/desktop/src/lib/browser-terminal.test.ts | 23 ++++++++++
apps/desktop/src/lib/browser-terminal.ts | 9 +++-
hermes_cli/web_host_terminal_sessions.py | 43 ++++++++++++++++++-
.../test_web_host_terminal_persistence.py | 33 +++++++++++++-
6 files changed, 134 insertions(+), 7 deletions(-)
diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminal-session-attempt.ts b/apps/desktop/src/app/right-sidebar/terminal/terminal-session-attempt.ts
index a70b0f67d3..47b62fa040 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/terminal-session-attempt.ts
+++ b/apps/desktop/src/app/right-sidebar/terminal/terminal-session-attempt.ts
@@ -243,10 +243,13 @@ export function createSessionAttemptController(
}
release()
- const expired = error && typeof error === 'object' && 'signal' in error && error.signal === 'expired'
+ const signal = error && typeof error === 'object' && 'signal' in error ? error.signal : null
const message = error instanceof Error ? error.message : String(error)
- if (expired) {
+ if (signal === 'exited') {
+ // The shell exited while no tab was attached: close the tab, as an attached exit does.
+ removeExitedTerminal(id)
+ } else if (signal === 'expired') {
resumeOnly = false
armRetry(TERMINAL_EXIT_MESSAGES.expired)
} else if (isBrowserHostedDesktop()) {
diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.test.tsx
index 74dce944a9..eefa28d78f 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.test.tsx
+++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.test.tsx
@@ -94,6 +94,8 @@ class Socket {
static OPEN = 1
static instances: Socket[] = []
static persistent = false
+ // Set: every new socket is closed with this instead of answering.
+ static refusal: [number, string] | null = null
url: URL
sent: string[] = []
readyState = 1
@@ -103,6 +105,12 @@ class Socket {
this.url = new URL(url)
Socket.instances.push(this)
queueMicrotask(() => {
+ if (Socket.refusal) {
+ this.close(...Socket.refusal)
+
+ return
+ }
+
const metadata = Socket.persistent
? { shell: 'fish', cwd: '/work', terminalId: this.url.searchParams.get('attach') || `shell-${Socket.instances.length}`, closed: this.url.searchParams.get('action') === 'close' }
: { shell: 'fish' }
@@ -169,6 +177,7 @@ afterEach(() => {
$terminals.set([])
Socket.instances = []
Socket.persistent = false
+ Socket.refusal = null
emulator.output = ''
vi.unstubAllGlobals()
vi.restoreAllMocks()
@@ -314,6 +323,23 @@ it('closes a persistent browser tab when its host shell exits', async () => {
expect(emulator.output).not.toContain('Press Enter to create a new shell')
})
+// The shell exited while no view was attached (`exit`, then the tab was hidden or the page reloaded).
+it('closes a persistent browser tab whose shell exited while it was detached', async () => {
+ seedWebappSession('test-token')
+ Socket.persistent = true
+ vi.stubGlobal('WebSocket', Socket)
+ installBrowserDesktopBridge()
+ const id = createTerminal('/work')
+ const firstView = render()
+ await waitFor(() => expect(screen.getByText('open')).toBeTruthy())
+ firstView.unmount()
+ Socket.refusal = [4410, 'Process exited']
+ render()
+ await waitFor(() => expect($terminals.get().some(tab => tab.id === id)).toBe(false))
+ expect(Socket.instances[1].url.searchParams.get('attach')).toBe('shell-1')
+ expect(emulator.output).not.toContain('Press Enter to create a new shell')
+})
+
it('close others and close all terminate mounted and unmounted persisted shells', async () => {
seedWebappSession('test-token')
Socket.persistent = true
diff --git a/apps/desktop/src/lib/browser-terminal.test.ts b/apps/desktop/src/lib/browser-terminal.test.ts
index 68a7ed21fe..9e15ac7297 100644
--- a/apps/desktop/src/lib/browser-terminal.test.ts
+++ b/apps/desktop/src/lib/browser-terminal.test.ts
@@ -201,6 +201,29 @@ describe('persistent browser terminals', () => {
expect(localStorage.length).toBe(0)
})
+ // The shell exited while the tab was away: the redial is refused with the exit reason.
+ it('reports a redial refused because the shell exited as an exit, and forgets it', async () => {
+ const api = install()
+ const session = await api.start({ cwd: '/original', restoreKey: 'tab-one' })
+ const exit = vi.fn()
+ api.onExit(session.id, exit)
+ Socket.instances[0].close(1006)
+ Socket.reply = socket => socket.close(4410, 'Process exited')
+ await vi.advanceTimersByTimeAsync(30_000)
+ expect(Socket.instances).toHaveLength(2)
+ expect(exit).toHaveBeenCalledExactlyOnceWith({ code: null, signal: null })
+ expect(localStorage.length).toBe(0)
+ })
+
+ it('rejects resuming a saved shell that exited with the exit signal, and forgets it', async () => {
+ const api = install()
+ await api.start({ cwd: '/original', restoreKey: 'tab-one' })
+ Socket.reply = socket => socket.close(4410, 'Process exited')
+ await expect(api.start({ restoreKey: 'tab-one', resumeOnly: true })).rejects.toMatchObject({ signal: 'exited' })
+ expect(Socket.instances[1].url.searchParams.get('attach')).toBe('opaque-server-id')
+ expect(localStorage.length).toBe(0)
+ })
+
it('reports a 4410 close without the exit reason as expired', async () => {
const api = install()
const session = await api.start({ cwd: '/original', restoreKey: 'tab-one' })
diff --git a/apps/desktop/src/lib/browser-terminal.ts b/apps/desktop/src/lib/browser-terminal.ts
index 040dba8747..ebe8f583e3 100644
--- a/apps/desktop/src/lib/browser-terminal.ts
+++ b/apps/desktop/src/lib/browser-terminal.ts
@@ -50,6 +50,8 @@ const META = '\u0000HERMES_TERMINAL_META:'
// 4410 also ends a shell the server closed (expiry, a deleted profile, shutdown); only a shell
// that exited by itself (`exit`, Ctrl-D) carries this reason (pty_session.WS_CLOSE_PROCESS_EXITED_REASON).
const PROCESS_EXITED_REASON = 'Process exited'
+// The signal of a dial refused that way (the shell exited while no tab was attached): a process exit.
+const EXITED = 'exited'
const MAX_RETRIES = 5
const RETRY_BACKOFF = { baseDelayMs: 500, capMs: 8000 }
const START_TIMEOUT = 10_000
@@ -258,11 +260,12 @@ async function dialTerminal(state: TerminalState, context: DialContext): Promise
if (!current()) {return}
settleOpen(state)
const signal = CLOSE_SIGNALS[event.code] ?? 'disconnected'
+ const exited = event.code === 4410 && event.reason === PROCESS_EXITED_REASON
if (signal === 'expired') {forget(state)}
if (!handshakeComplete) {
- fail(new TerminalConnectionError(event.reason || `Host terminal closed (${event.code})`, signal))
+ fail(new TerminalConnectionError(event.reason || `Host terminal closed (${event.code})`, exited ? EXITED : signal))
return
}
@@ -271,7 +274,7 @@ async function dialTerminal(state: TerminalState, context: DialContext): Promise
if (!state.terminalId) {
finish(state, event.code === 4410 ? null : 'disconnected')
- } else if (event.code === 4410 && event.reason === PROCESS_EXITED_REASON) {
+ } else if (exited) {
// The shell ended: report a process exit, which closes the tab as in native Desktop.
finish(state, null)
} else if (signal !== 'disconnected') {
@@ -374,6 +377,8 @@ export function createBrowserTerminal(options: BrowserTerminalOptions): Terminal
// A 401 from the ticket route: signed out, and every further attempt would be refused.
if (isGatewayReauthRequired(error)) {
finish(state, 'signed-out')
+ } else if (error instanceof TerminalConnectionError && error.signal === EXITED) {
+ finish(state, null)
} else if (error instanceof TerminalConnectionError && error.signal !== 'disconnected') {
finish(state, error.signal)
} else {
diff --git a/hermes_cli/web_host_terminal_sessions.py b/hermes_cli/web_host_terminal_sessions.py
index ca303501cd..8132330632 100644
--- a/hermes_cli/web_host_terminal_sessions.py
+++ b/hermes_cli/web_host_terminal_sessions.py
@@ -16,6 +16,7 @@ import json
from pathlib import Path
import re
import secrets
+import time
from fastapi import HTTPException, WebSocket, WebSocketDisconnect
@@ -44,6 +45,10 @@ class TerminalExpired(Exception):
pass
+class TerminalExited(TerminalExpired):
+ """The shell exited by itself (``exit``, Ctrl-D); the server did not end it."""
+
+
def _home_live(home: Path) -> bool:
"""A named home must exist and carry no deletion tombstone; any other home only has to exist."""
try:
@@ -95,6 +100,10 @@ def _request_home(profile: str | None) -> Path:
class HostTerminalRegistry(PtySessionRegistry):
+ # The most exited shells remembered at once: a tab that redials after the reaper took its
+ # shell still learns that it exited (``resolve``).
+ _EXITS_KEPT = 4 * _MAX_SHELLS
+
def __init__(self):
# Every live shell keeps a read in flight. On the loop's default executor, retained
# shells would queue the keystroke fences, ownership checks, uploads and SessionDB
@@ -103,6 +112,9 @@ class HostTerminalRegistry(PtySessionRegistry):
ttl=RETENTION_SECONDS, max_sessions=_MAX_SHELLS, buffer_cap=1024 * 1024, read_timeout=0.2,
drain_executor=ThreadPoolExecutor(_MAX_SHELLS, thread_name_prefix="host-pty-drain"))
self.owners: dict[str, HostOwner] = {}
+ # Token -> (owner identity, when it was reaped) of shells that exited by themselves, for
+ # the retention TTL.
+ self.exits: dict[str, tuple[tuple[str, str], float]] = {}
def _reap_one_idle_or_raise(self) -> None:
# Retention is a promise, not an LRU hint; never evict a live shell to
@@ -140,6 +152,9 @@ class HostTerminalRegistry(PtySessionRegistry):
session = self._sessions.get(token)
owner = self.owners.get(token)
if session is None or owner is None:
+ gone = self.exits.get(token)
+ if gone is not None and gone[0] == identity:
+ raise TerminalExited()
raise TerminalExpired()
if owner.identity != identity:
raise TerminalDenied()
@@ -152,8 +167,11 @@ class HostTerminalRegistry(PtySessionRegistry):
await asyncio.to_thread(validate)
# No await between the final check and the caller's attach claim. The
# home was just checked off-loop; every input write checks it again.
- if (self._sessions.get(token) is not session
- or (not closing and (not session.alive or self.expired(session)))):
+ if self._sessions.get(token) is not session:
+ raise TerminalExpired()
+ if not closing and _exited(session):
+ raise TerminalExited()
+ if not closing and self.expired(session):
raise TerminalExpired()
return session, owner
@@ -169,22 +187,39 @@ class HostTerminalRegistry(PtySessionRegistry):
# One thread hop per tick keeps every shell's marker reads off the loop.
owners = list(self.owners.items())
retired = await asyncio.to_thread(lambda: {token for token, owner in owners if not owner.current()})
+ now = time.monotonic() if now is None else now
for token, owner in owners:
session = self._sessions.get(token)
if session is not None and (token in retired or self._reapable(session, now)):
+ if token not in retired and _exited(session):
+ self._remember_exit(token, owner, now)
await self.remove(token)
for token in list(self.owners):
if token not in self._sessions:
self.owners.pop(token, None)
+ for token in [token for token, (_, at) in self.exits.items() if now - at > self._ttl]:
+ del self.exits[token]
+
+ def _remember_exit(self, token: str, owner: HostOwner, now: float) -> None:
+ self.exits[token] = (owner.identity, now)
+ while len(self.exits) > self._EXITS_KEPT:
+ del self.exits[next(iter(self.exits))]
async def close_all(self):
await super().close_all()
self.owners.clear()
+ self.exits.clear()
# Every drain is cancelled by now; a read still in flight returns within its
# timeout, so let it finish behind us. A closed registry never spawns again.
self._drain_executor.shutdown(wait=False)
+def _exited(session: PtySession) -> bool:
+ """Whether a session still in the registry lost its shell: the server pops a session before
+ it closes one, so only the shell's own exit leaves a dead one behind."""
+ return not session.alive or not session.bridge.is_alive()
+
+
def get_host_terminals(app) -> HostTerminalRegistry:
registry = getattr(app.state, "host_terminals", None)
if registry is None or registry._closed:
@@ -418,6 +453,7 @@ async def _pump_input(ws: WebSocket, registry: HostTerminalRegistry, token: str,
async def host_terminal(ws: WebSocket) -> None:
"""Called only after both the existing WS gate and host policy have passed."""
+ from hermes_cli.pty_session import WS_CLOSE_PROCESS_EXITED, WS_CLOSE_PROCESS_EXITED_REASON
from hermes_cli.web_server_chat import _pty_fail
session = None
try:
@@ -440,6 +476,9 @@ async def host_terminal(ws: WebSocket) -> None:
await _pump_input(ws, registry, token, session, owner)
except TerminalDenied:
await ws.close(code=4403, reason="Terminal belongs to another identity or profile")
+ except TerminalExited:
+ # The same close an attached tab gets when its shell exits: the tab closes too.
+ await ws.close(code=WS_CLOSE_PROCESS_EXITED, reason=WS_CLOSE_PROCESS_EXITED_REASON)
except (TerminalExpired, FileNotFoundError, HTTPException):
await ws.close(code=4410, reason="Terminal or profile expired; open a new terminal")
except RegistryFull:
diff --git a/tests/hermes_cli/test_web_host_terminal_persistence.py b/tests/hermes_cli/test_web_host_terminal_persistence.py
index da0ee4c5d3..e59e1c7d6f 100644
--- a/tests/hermes_cli/test_web_host_terminal_persistence.py
+++ b/tests/hermes_cli/test_web_host_terminal_persistence.py
@@ -11,7 +11,7 @@ from starlette.websockets import WebSocketDisconnect
from hermes_cli import web_host_terminal, web_server, web_server_chat
from hermes_cli.web_routers.webapp import router
from hermes_cli.web_session_sockets import HOST_TERMINAL_SOCKET
-from tests.waiting import async_wait_until
+from tests.waiting import async_wait_until, wait_until
@pytest.fixture
@@ -384,6 +384,37 @@ def test_real_profile_a_b_a_and_process_exit_never_respawns(host_app, tmp_path):
assert set(host_app.state.host_terminals._sessions) <= set(tokens.values())
+@pytest.mark.platforms("linux")
+@pytest.mark.parametrize("reaped", [False, True], ids=["before-reap", "after-reap"])
+def test_a_shell_that_exits_while_detached_closes_the_redial_as_an_exit(host_app, tmp_path, monkeypatch, reaped):
+ """A tab that redials after its shell exited (``exit`` while the tab was away) is told the
+ shell exited, as an attached tab is, so it closes instead of offering a new shell. That holds
+ before the reaper takes the dead shell and after."""
+ from hermes_cli import web_host_terminal_sessions
+ if not reaped:
+ async def no_reaper(_registry, *, interval):
+ await asyncio.Event().wait()
+ monkeypatch.setattr(web_host_terminal_sessions, "run_reaper", no_reaper)
+ go = tmp_path / "go"
+ with TestClient(host_app) as client:
+ registry = host_app.state.host_terminals
+ with client.websocket_connect(url()) as ws:
+ token = metadata(ws)["terminalId"]
+ ws.send_bytes(f"printf 'WAIT%s\\n' ING; while [ ! -e '{go}' ]; do sleep 0.05; done; exit\n".encode())
+ output_until(ws, b"WAITING")
+ session = registry._sessions[token]
+ wait_until(lambda: not session.attached, what="the tab detached")
+ go.touch()
+ if reaped:
+ wait_until(lambda: token not in registry._sessions, what="the reaper took the shell")
+ else:
+ wait_until(lambda: not session.alive, what="the shell exited")
+ with pytest.raises(WebSocketDisconnect) as error:
+ with client.websocket_connect(url("&attach=" + token)) as ws:
+ ws.receive_text()
+ assert (error.value.code, error.value.reason) == (4410, "Process exited")
+
+
@pytest.mark.platforms("linux")
def test_real_shell_rejects_input_after_profile_deletion(host_app, tmp_path):
import os
From 4fe9e6273d8377a4db426f4950b0192775cefec7 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 01:20:19 +0000
Subject: [PATCH 073/114] fix(webapp): report a host shell exit reaped during
redial validation
HostTerminalRegistry.resolve checks the redial's profile off-loop after
it captures the session. If the reaper takes a shell that exited during
that check, it records the exit, but resolve then saw the session gone
and raised the generic TerminalExpired without looking at that record.
The tab stayed open and offered a new shell.
When the session is gone after validation, resolve now consults the
exit record the same way it does for a token that is already gone, so
the owner's redial gets TerminalExited and the route closes it with
'Process exited'. The exit record is only read after the owner identity
and profile checks have passed.
_admit, _read_frames, _queue_frame and the constants block are not
touched.
The new test holds profile validation on an event, reaps the dead
shell while it is held, then releases it. At 9e80e5890dd7 it fails
with TerminalExpired.
---
hermes_cli/web_host_terminal_sessions.py | 14 +++++---
tests/hermes_cli/test_pty_session.py | 46 ++++++++++++++++++++++++
2 files changed, 55 insertions(+), 5 deletions(-)
diff --git a/hermes_cli/web_host_terminal_sessions.py b/hermes_cli/web_host_terminal_sessions.py
index 8132330632..714b7af68b 100644
--- a/hermes_cli/web_host_terminal_sessions.py
+++ b/hermes_cli/web_host_terminal_sessions.py
@@ -152,10 +152,7 @@ class HostTerminalRegistry(PtySessionRegistry):
session = self._sessions.get(token)
owner = self.owners.get(token)
if session is None or owner is None:
- gone = self.exits.get(token)
- if gone is not None and gone[0] == identity:
- raise TerminalExited()
- raise TerminalExpired()
+ raise self._gone(token, identity)
if owner.identity != identity:
raise TerminalDenied()
@@ -168,13 +165,20 @@ class HostTerminalRegistry(PtySessionRegistry):
# No await between the final check and the caller's attach claim. The
# home was just checked off-loop; every input write checks it again.
if self._sessions.get(token) is not session:
- raise TerminalExpired()
+ # The reaper took the shell during validation; it may have exited first.
+ raise self._gone(token, identity)
if not closing and _exited(session):
raise TerminalExited()
if not closing and self.expired(session):
raise TerminalExpired()
return session, owner
+ def _gone(self, token: str, identity: tuple[str, str]) -> TerminalExpired:
+ """The refusal of a token with no registered shell: ``TerminalExited`` if *identity*'s shell
+ exited by itself, else ``TerminalExpired``."""
+ gone = self.exits.get(token)
+ return TerminalExited() if gone is not None and gone[0] == identity else TerminalExpired()
+
async def remove(self, token: str):
session = self._sessions.pop(token, None)
self.owners.pop(token, None)
diff --git a/tests/hermes_cli/test_pty_session.py b/tests/hermes_cli/test_pty_session.py
index 58812b3348..5706090822 100644
--- a/tests/hermes_cli/test_pty_session.py
+++ b/tests/hermes_cli/test_pty_session.py
@@ -374,6 +374,52 @@ async def test_a_reattach_that_replays_part_of_the_history_says_output_was_trimm
await registry.close_all()
+@pytest.mark.asyncio
+async def test_a_shell_reaped_while_its_redial_is_validated_closes_the_redial_as_an_exit(tmp_path, monkeypatch):
+ """A redial whose shell exited, and which the reaper takes while ``resolve`` checks the
+ profile off-loop, is still told the shell exited, so the tab closes instead of offering a new
+ shell."""
+ import secrets
+ import threading
+ from types import SimpleNamespace
+
+ from hermes_cli import pty_session, web_host_terminal_sessions
+ from hermes_cli.web_host_terminal_sessions import HostOwner, HostTerminalRegistry, TerminalExited
+
+ home = tmp_path / "home"
+ home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ identity = ("local", "owner")
+ registry = HostTerminalRegistry()
+ token = secrets.token_urlsafe(32)
+ bridge = FakeBridge([])
+ bridge.dead = True # the shell exited while no tab was attached
+ registry._sessions[token] = pty_session.PtySession(token, bridge, buffer_cap=1024, read_timeout=0.01)
+ registry.owners[token] = HostOwner(identity, home.resolve(), "sh", str(tmp_path))
+
+ validating, release = threading.Event(), threading.Event()
+ request_home = web_host_terminal_sessions._request_home
+
+ def held_request_home(profile):
+ validating.set()
+ release.wait(5)
+ return request_home(profile)
+
+ monkeypatch.setattr(web_host_terminal_sessions, "_request_home", held_request_home)
+ redial = SimpleNamespace(query_params={"attach": token})
+ try:
+ resolving = asyncio.ensure_future(registry.resolve(token, redial, identity))
+ assert await asyncio.to_thread(validating.wait, 5)
+ await registry.reap_idle()
+ assert token not in registry._sessions and token in registry.exits
+ release.set()
+ with pytest.raises(TerminalExited):
+ await resolving
+ finally:
+ release.set()
+ await registry.close_all()
+
+
@pytest.mark.asyncio
async def test_reattach_can_force_complete_tui_redraw_after_replay():
"""A fresh terminal cannot reconstruct a differential ANSI tail alone."""
From 72e47c1ff139c91b082bbfca7a2e930f80973dd5 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 02:46:01 +0000
Subject: [PATCH 074/114] test(webapp): move the reaped-redial exit test next
to the close-reason tests
test_a_shell_reaped_while_its_redial_is_validated_closes_the_redial_as_an_exit went into
test_pty_session.py with 3d789e564a92, which put that file back over 1,000 lines. Move it
unchanged into test_pty_session_close_reason.py, which already imports FakeBridge.
---
tests/hermes_cli/test_pty_session.py | 46 ------------------
.../test_pty_session_close_reason.py | 48 +++++++++++++++++++
2 files changed, 48 insertions(+), 46 deletions(-)
diff --git a/tests/hermes_cli/test_pty_session.py b/tests/hermes_cli/test_pty_session.py
index 5706090822..58812b3348 100644
--- a/tests/hermes_cli/test_pty_session.py
+++ b/tests/hermes_cli/test_pty_session.py
@@ -374,52 +374,6 @@ async def test_a_reattach_that_replays_part_of_the_history_says_output_was_trimm
await registry.close_all()
-@pytest.mark.asyncio
-async def test_a_shell_reaped_while_its_redial_is_validated_closes_the_redial_as_an_exit(tmp_path, monkeypatch):
- """A redial whose shell exited, and which the reaper takes while ``resolve`` checks the
- profile off-loop, is still told the shell exited, so the tab closes instead of offering a new
- shell."""
- import secrets
- import threading
- from types import SimpleNamespace
-
- from hermes_cli import pty_session, web_host_terminal_sessions
- from hermes_cli.web_host_terminal_sessions import HostOwner, HostTerminalRegistry, TerminalExited
-
- home = tmp_path / "home"
- home.mkdir()
- monkeypatch.setenv("HERMES_HOME", str(home))
- identity = ("local", "owner")
- registry = HostTerminalRegistry()
- token = secrets.token_urlsafe(32)
- bridge = FakeBridge([])
- bridge.dead = True # the shell exited while no tab was attached
- registry._sessions[token] = pty_session.PtySession(token, bridge, buffer_cap=1024, read_timeout=0.01)
- registry.owners[token] = HostOwner(identity, home.resolve(), "sh", str(tmp_path))
-
- validating, release = threading.Event(), threading.Event()
- request_home = web_host_terminal_sessions._request_home
-
- def held_request_home(profile):
- validating.set()
- release.wait(5)
- return request_home(profile)
-
- monkeypatch.setattr(web_host_terminal_sessions, "_request_home", held_request_home)
- redial = SimpleNamespace(query_params={"attach": token})
- try:
- resolving = asyncio.ensure_future(registry.resolve(token, redial, identity))
- assert await asyncio.to_thread(validating.wait, 5)
- await registry.reap_idle()
- assert token not in registry._sessions and token in registry.exits
- release.set()
- with pytest.raises(TerminalExited):
- await resolving
- finally:
- release.set()
- await registry.close_all()
-
-
@pytest.mark.asyncio
async def test_reattach_can_force_complete_tui_redraw_after_replay():
"""A fresh terminal cannot reconstruct a differential ANSI tail alone."""
diff --git a/tests/hermes_cli/test_pty_session_close_reason.py b/tests/hermes_cli/test_pty_session_close_reason.py
index d700d93565..268c7cc15c 100644
--- a/tests/hermes_cli/test_pty_session_close_reason.py
+++ b/tests/hermes_cli/test_pty_session_close_reason.py
@@ -1,6 +1,8 @@
"""4410 also ends a shell the server closed (expiry, a deleted profile, shutdown). Only a shell that
exited by itself says so in the close reason, so a viewer can end its tab the way native Desktop
does when its shell exits, and keep the tab for the other cases."""
+import asyncio
+
import pytest
from tests.hermes_cli.test_pty_session import FakeBridge, FakeWS
@@ -42,3 +44,49 @@ async def test_a_server_close_keeps_4410_without_the_exit_reason():
await s.close()
assert ws.close_code == 4410
assert ws.close_reason != "Process exited"
+
+
+@pytest.mark.asyncio
+async def test_a_shell_reaped_while_its_redial_is_validated_closes_the_redial_as_an_exit(tmp_path, monkeypatch):
+ """A redial whose shell exited, and which the reaper takes while ``resolve`` checks the
+ profile off-loop, is still told the shell exited, so the tab closes instead of offering a new
+ shell."""
+ import secrets
+ import threading
+ from types import SimpleNamespace
+
+ from hermes_cli import pty_session, web_host_terminal_sessions
+ from hermes_cli.web_host_terminal_sessions import HostOwner, HostTerminalRegistry, TerminalExited
+
+ home = tmp_path / "home"
+ home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ identity = ("local", "owner")
+ registry = HostTerminalRegistry()
+ token = secrets.token_urlsafe(32)
+ bridge = FakeBridge([])
+ bridge.dead = True # the shell exited while no tab was attached
+ registry._sessions[token] = pty_session.PtySession(token, bridge, buffer_cap=1024, read_timeout=0.01)
+ registry.owners[token] = HostOwner(identity, home.resolve(), "sh", str(tmp_path))
+
+ validating, release = threading.Event(), threading.Event()
+ request_home = web_host_terminal_sessions._request_home
+
+ def held_request_home(profile):
+ validating.set()
+ release.wait(5)
+ return request_home(profile)
+
+ monkeypatch.setattr(web_host_terminal_sessions, "_request_home", held_request_home)
+ redial = SimpleNamespace(query_params={"attach": token})
+ try:
+ resolving = asyncio.ensure_future(registry.resolve(token, redial, identity))
+ assert await asyncio.to_thread(validating.wait, 5)
+ await registry.reap_idle()
+ assert token not in registry._sessions and token in registry.exits
+ release.set()
+ with pytest.raises(TerminalExited):
+ await resolving
+ finally:
+ release.set()
+ await registry.close_all()
From dbe74c61df356c0ed071e6a1350326d23d158ed9 Mon Sep 17 00:00:00 2001
From: Adolanium <94890352+Adolanium@users.noreply.github.com>
Date: Sun, 25 Oct 2026 22:41:20 +0200
Subject: [PATCH 075/114] fix(webapp): return a clear error when the SSH file
host is down
(cherry picked from commit 750c83d922fba6604588e07e4621420728ae50fa)
---
hermes_cli/web_routers/files.py | 15 ++++++--
tests/hermes_cli/test_web_server_fs.py | 52 ++++++++++++++++++++++++++
2 files changed, 64 insertions(+), 3 deletions(-)
diff --git a/hermes_cli/web_routers/files.py b/hermes_cli/web_routers/files.py
index 938c5eaa4d..d61f6a3a37 100644
--- a/hermes_cli/web_routers/files.py
+++ b/hermes_cli/web_routers/files.py
@@ -294,8 +294,14 @@ def _fs_git_branch(cwd: str) -> str:
def _fs_backend(profile: Optional[str] = None):
- """Return the profile's SSH workspace adapter, or None for host-local FS."""
- from hermes_cli.ssh_workspace_fs import get_ssh_workspace_fs
+ """Return the profile's SSH workspace adapter, or None for host-local FS.
+
+ A refused connection fails while the adapter is opened, before any path is
+ read. That is the same 503 the image route returns (``SSH workspace is
+ unavailable``), not an escaped 500. A missing file or a permission error
+ comes from an adapter that already connected, and keeps its own status.
+ """
+ from hermes_cli.ssh_workspace_fs import SshWorkspaceFsError, get_ssh_workspace_fs
with _profile_scope(profile):
terminal = dict(load_config().get("terminal") or {})
@@ -309,7 +315,10 @@ def _fs_backend(profile: Optional[str] = None):
if not terminal.get(key) and profile_env.get(env_name):
terminal[key] = profile_env[env_name]
profile_key = (profile or "current").strip().lower() or "current"
- return get_ssh_workspace_fs(profile_key, terminal)
+ try:
+ return get_ssh_workspace_fs(profile_key, terminal)
+ except SshWorkspaceFsError as exc:
+ _raise_fs_backend_error(exc)
def _chat_image_dir(home: Path) -> Path:
diff --git a/tests/hermes_cli/test_web_server_fs.py b/tests/hermes_cli/test_web_server_fs.py
index d58bf54ae8..7876f1b912 100644
--- a/tests/hermes_cli/test_web_server_fs.py
+++ b/tests/hermes_cli/test_web_server_fs.py
@@ -685,6 +685,58 @@ def test_a_browser_paste_reads_back_while_the_ssh_machine_is_unreachable(client,
assert (response.status_code, response.json()) == (503, {"detail": "SSH workspace is unavailable"})
+def test_a_down_ssh_host_is_unavailable_on_workspace_file_routes(client, monkeypatch, tmp_path):
+ """List, read, git-root and the default folder answer 503 when the SSH host is down, the same
+ error the image route already returns. A file that is missing or unreadable on a host that did
+ connect is not that error, and a workspace that is not SSH still reads this machine."""
+ note = tmp_path / "note.txt"
+ note.write_text("on this machine")
+ local = client.get("/api/fs/read-text", params={"path": str(note)})
+ assert (local.status_code, local.json()["text"]) == (200, "on this machine")
+
+ class UnreachableSsh:
+ def __init__(self, **_options):
+ raise ConnectionRefusedError("ssh: connect to host 127.0.0.1 port 1: Connection refused")
+
+ monkeypatch.setattr(ssh_workspace_fs, "SSHEnvironment", UnreachableSsh)
+ monkeypatch.setattr(ssh_workspace_fs, "_BACKENDS", {})
+ monkeypatch.setattr(
+ file_routes, "load_config",
+ lambda: {"terminal": {"backend": "ssh", "ssh_host": "127.0.0.1", "ssh_user": "dev", "ssh_port": 1}},
+ )
+ monkeypatch.setattr(file_routes, "load_env", dict)
+
+ unavailable = (503, {"detail": "SSH workspace is unavailable"})
+ for path, params in (
+ ("/api/fs/list", {"path": "/srv/repos"}),
+ ("/api/fs/read-text", {"path": "/srv/repos/README.md"}),
+ ("/api/fs/git-root", {"path": "/srv/repos"}),
+ ("/api/fs/default-cwd", {}),
+ ):
+ response = client.get(path, params=params)
+ assert (response.status_code, response.json()) == unavailable, path
+ assert "Traceback" not in response.text
+
+ class Reachable:
+ def list_dir(self, path, _hidden):
+ code = "EACCES" if path.endswith("secret") else "ENOENT"
+ return {"entries": [], "error": code}
+
+ def read_bytes(self, path, *, max_bytes, read_limit=None):
+ code = "EACCES" if path.endswith("secret") else "ENOENT"
+ raise ssh_workspace_fs.SshWorkspaceFsError(code)
+
+ monkeypatch.setattr(file_routes, "_fs_backend", lambda profile=None: Reachable())
+ missing = client.get("/api/fs/list", params={"path": "/srv/missing"})
+ denied = client.get("/api/fs/list", params={"path": "/srv/secret"})
+ assert (missing.status_code, missing.json()) == (200, {"entries": [], "error": "ENOENT"})
+ assert (denied.status_code, denied.json()) == (200, {"entries": [], "error": "EACCES"})
+ missing_text = client.get("/api/fs/read-text", params={"path": "/srv/missing.txt"})
+ denied_text = client.get("/api/fs/read-text", params={"path": "/srv/secret"})
+ assert (missing_text.status_code, missing_text.json()) == (404, {"detail": "File not found"})
+ assert (denied_text.status_code, denied_text.json()) == (403, {"detail": "File is not readable"})
+
+
class _NotOnTheSshMachine:
"""An SSH workspace that has none of the files a test asks it for; it records each ask."""
From e749ca3638d388aec0eb4be9d1d53d7c48ce3a98 Mon Sep 17 00:00:00 2001
From: Adolanium <94890352+Adolanium@users.noreply.github.com>
Date: Sun, 25 Oct 2026 23:54:56 +0200
Subject: [PATCH 076/114] fix(webapp): log why the SSH workspace is unavailable
and drop the dead mapping
_fs_backend now maps a failed adapter open to the 503, so the same except in
_remote_fs_backend could no longer fire. Read-data-url and download keep their
503 through _fs_backend.
The 503 folds several causes into one answer: a refused or timed-out dial, no
ssh on PATH, no host or user set. Before the fix those routes logged a 500
traceback; now _fs_backend logs one warning with the underlying cause so an
operator can tell them apart.
The docstrings now say what is true: only an uncached adapter dials. Once one
is cached, a host that goes down fails the read itself, which each route
answers on its own (list read-error, read-text 502, git-root null).
The regression test now goes through the real _fs_backend with only the SSH
environment faked, covers POST /api/fs/write-text, checks the log line, and,
once the host answers, that a missing folder or an unreadable file keeps its
own answer instead of the 503.
(cherry picked from commit 75e893c31913f09682674e8f646a8e41fa338e4c)
---
hermes_cli/web_routers/files.py | 22 ++++----
tests/hermes_cli/test_web_server_fs.py | 70 ++++++++++++++------------
2 files changed, 49 insertions(+), 43 deletions(-)
diff --git a/hermes_cli/web_routers/files.py b/hermes_cli/web_routers/files.py
index d61f6a3a37..2ee8edf6fa 100644
--- a/hermes_cli/web_routers/files.py
+++ b/hermes_cli/web_routers/files.py
@@ -8,6 +8,7 @@ import asyncio
import base64
import binascii
import contextlib
+import logging
import mimetypes
import os
import re
@@ -44,6 +45,7 @@ from hermes_cli.web_models import (
)
router = APIRouter()
+logger = logging.getLogger(__name__)
# Late-bound so a test's monkeypatch on the owning module wins at call time.
_profile_scope = late("_profile_scope", "hermes_cli.web_server_profiles")
@@ -296,10 +298,12 @@ def _fs_git_branch(cwd: str) -> str:
def _fs_backend(profile: Optional[str] = None):
"""Return the profile's SSH workspace adapter, or None for host-local FS.
- A refused connection fails while the adapter is opened, before any path is
- read. That is the same 503 the image route returns (``SSH workspace is
- unavailable``), not an escaped 500. A missing file or a permission error
- comes from an adapter that already connected, and keeps its own status.
+ Only an uncached adapter dials the host: the profile's first file request since the server
+ started, or the first after a setting the adapter is built from changes. When that dial fails,
+ or the settings cannot dial at all (no host or user, no ``ssh`` on PATH), the request answers
+ 503 ``SSH workspace is unavailable`` and the log names the cause the 503 hides. A cached
+ adapter is not re-dialed here: a host that goes down after it connected fails the read itself,
+ which each route answers on its own.
"""
from hermes_cli.ssh_workspace_fs import SshWorkspaceFsError, get_ssh_workspace_fs
@@ -318,6 +322,7 @@ def _fs_backend(profile: Optional[str] = None):
try:
return get_ssh_workspace_fs(profile_key, terminal)
except SshWorkspaceFsError as exc:
+ logger.warning("SSH workspace for profile %s is unavailable: %s", profile_key, exc.__cause__ or exc)
_raise_fs_backend_error(exc)
@@ -354,15 +359,10 @@ def _is_host_file(path: str, profile: Optional[str]) -> bool:
def _remote_fs_backend(path: str, profile: Optional[str]):
"""The SSH workspace reader for *path*, or None when this host owns it. The host's pastes and
staged uploads are checked before the SSH machine is dialed, so they read back while that
- machine is down; other paths answer 503 until it is back."""
- from hermes_cli.ssh_workspace_fs import SshWorkspaceFsError
-
+ machine is down; every other path is the SSH machine's to answer (see ``_fs_backend``)."""
if _is_host_file(path, profile):
return None
- try:
- return _fs_backend(profile)
- except SshWorkspaceFsError as exc:
- _raise_fs_backend_error(exc)
+ return _fs_backend(profile)
def _raise_fs_backend_error(exc: Exception) -> None:
diff --git a/tests/hermes_cli/test_web_server_fs.py b/tests/hermes_cli/test_web_server_fs.py
index 7876f1b912..42317fc749 100644
--- a/tests/hermes_cli/test_web_server_fs.py
+++ b/tests/hermes_cli/test_web_server_fs.py
@@ -685,20 +685,34 @@ def test_a_browser_paste_reads_back_while_the_ssh_machine_is_unreachable(client,
assert (response.status_code, response.json()) == (503, {"detail": "SSH workspace is unavailable"})
-def test_a_down_ssh_host_is_unavailable_on_workspace_file_routes(client, monkeypatch, tmp_path):
- """List, read, git-root and the default folder answer 503 when the SSH host is down, the same
- error the image route already returns. A file that is missing or unreadable on a host that did
- connect is not that error, and a workspace that is not SSH still reads this machine."""
- note = tmp_path / "note.txt"
- note.write_text("on this machine")
- local = client.get("/api/fs/read-text", params={"path": str(note)})
- assert (local.status_code, local.json()["text"]) == (200, "on this machine")
+def test_a_down_ssh_host_is_unavailable_on_workspace_file_routes(client, monkeypatch, caplog):
+ """The profile's first file request opens its SSH adapter, which dials the host. While the host
+ refuses, list, read-text, write-text, git-root and the default folder answer 503, the error the
+ image route and download already give, and the log names the refusal. Once the host answers,
+ the next request dials again, and a folder that is not there or a file the SSH user cannot read
+ keeps its own answer instead of that 503."""
+ from tools.environments.base import EnvironmentConnectionError
+
+ class SshHost:
+ """The SSH machine: refuses the dial while ``down``; once up, it has no /srv/missing and
+ its SSH user cannot read /srv/secret."""
+
+ down = True
+ cwd, timeout, _remote_home = "/srv/repos", 30, "/home/dev"
- class UnreachableSsh:
def __init__(self, **_options):
- raise ConnectionRefusedError("ssh: connect to host 127.0.0.1 port 1: Connection refused")
+ if SshHost.down:
+ raise EnvironmentConnectionError(
+ "SSH connection failed: ssh: connect to host 127.0.0.1 port 1: Connection refused")
- monkeypatch.setattr(ssh_workspace_fs, "SSHEnvironment", UnreachableSsh)
+ def execute(self, command, **_options):
+ code, rc = ("EACCES", 46) if "/srv/secret" in command else ("ENOENT", 44)
+ return {"returncode": rc, "output": f"__HERMES_FS_ERROR__:{code}\n"}
+
+ def cleanup(self):
+ pass
+
+ monkeypatch.setattr(ssh_workspace_fs, "SSHEnvironment", SshHost)
monkeypatch.setattr(ssh_workspace_fs, "_BACKENDS", {})
monkeypatch.setattr(
file_routes, "load_config",
@@ -707,32 +721,24 @@ def test_a_down_ssh_host_is_unavailable_on_workspace_file_routes(client, monkeyp
monkeypatch.setattr(file_routes, "load_env", dict)
unavailable = (503, {"detail": "SSH workspace is unavailable"})
- for path, params in (
- ("/api/fs/list", {"path": "/srv/repos"}),
- ("/api/fs/read-text", {"path": "/srv/repos/README.md"}),
- ("/api/fs/git-root", {"path": "/srv/repos"}),
- ("/api/fs/default-cwd", {}),
- ):
- response = client.get(path, params=params)
- assert (response.status_code, response.json()) == unavailable, path
- assert "Traceback" not in response.text
+ with caplog.at_level("WARNING", logger=file_routes.__name__):
+ for response in (
+ client.get("/api/fs/list", params={"path": "/srv/repos"}),
+ client.get("/api/fs/read-text", params={"path": "/srv/repos/README.md"}),
+ client.post("/api/fs/write-text", json={"path": "/srv/repos/README.md", "content": "x"}),
+ client.get("/api/fs/git-root", params={"path": "/srv/repos"}),
+ client.get("/api/fs/default-cwd"),
+ ):
+ assert (response.status_code, response.json()) == unavailable, response.request.url
+ assert "Connection refused" in caplog.text
- class Reachable:
- def list_dir(self, path, _hidden):
- code = "EACCES" if path.endswith("secret") else "ENOENT"
- return {"entries": [], "error": code}
-
- def read_bytes(self, path, *, max_bytes, read_limit=None):
- code = "EACCES" if path.endswith("secret") else "ENOENT"
- raise ssh_workspace_fs.SshWorkspaceFsError(code)
-
- monkeypatch.setattr(file_routes, "_fs_backend", lambda profile=None: Reachable())
+ SshHost.down = False
missing = client.get("/api/fs/list", params={"path": "/srv/missing"})
denied = client.get("/api/fs/list", params={"path": "/srv/secret"})
+ missing_text = client.get("/api/fs/read-text", params={"path": "/srv/missing/a.txt"})
+ denied_text = client.get("/api/fs/read-text", params={"path": "/srv/secret"})
assert (missing.status_code, missing.json()) == (200, {"entries": [], "error": "ENOENT"})
assert (denied.status_code, denied.json()) == (200, {"entries": [], "error": "EACCES"})
- missing_text = client.get("/api/fs/read-text", params={"path": "/srv/missing.txt"})
- denied_text = client.get("/api/fs/read-text", params={"path": "/srv/secret"})
assert (missing_text.status_code, missing_text.json()) == (404, {"detail": "File not found"})
assert (denied_text.status_code, denied_text.json()) == (403, {"detail": "File is not readable"})
From c5ec24ac2018802f4cf4bd089280bb8fe48e4765 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Sun, 25 Oct 2026 23:51:04 +0000
Subject: [PATCH 077/114] fix(webapp): play a Docker session's media from its
own workspace sandbox
The browser stream bridge sent only the path and profile, so a ticket for
/workspace/clip.mp4 resolved in the profile's shared sandbox. A session
bound to a host workspace runs in its own :ws: container, so
its clip was missing or another session's copy played.
getGatewayFileStreamUrl takes an optional sessionId, the transcript player
and tool media slots pass their session, and /api/files/stream maps a
session_id through _fs_download_path (#143655) for a cookie-authenticated
request too. A session the profile does not own is a 404, and a symlink or
.. out of the sandbox is not translated. Native Desktop has no stream bridge
and is unchanged.
---
.../assistant-ui/tool-media-slot.tsx | 4 +-
apps/desktop/src/global.d.ts | 2 +
.../src/hooks/use-media-playback-src.ts | 16 ++-
apps/desktop/src/lib/browser-files.ts | 3 +-
.../browser-session-workspace-files.test.tsx | 90 ++++++++++++
apps/desktop/src/lib/media.ts | 6 +-
hermes_cli/web_routers/files.py | 13 +-
.../test_web_server_fs_session_workspace.py | 129 ++++++++++++++++++
8 files changed, 250 insertions(+), 13 deletions(-)
create mode 100644 apps/desktop/src/lib/browser-session-workspace-files.test.tsx
create mode 100644 tests/hermes_cli/test_web_server_fs_session_workspace.py
diff --git a/apps/desktop/src/components/assistant-ui/tool-media-slot.tsx b/apps/desktop/src/components/assistant-ui/tool-media-slot.tsx
index 9ea6643f40..e8a4dcc172 100644
--- a/apps/desktop/src/components/assistant-ui/tool-media-slot.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool-media-slot.tsx
@@ -167,10 +167,10 @@ const ToolMediaLeaf: FC<{
// a split-view tile can belong to another connection than the foreground.
resolveUrl: () =>
['audio', 'video'].includes(mediaKind(path))
- ? resolveMediaPlaybackSrc(path)
+ ? resolveMediaPlaybackSrc(path, owner, sessionId ?? undefined)
: resolveMediaDisplaySrc(path, owner)
}),
- [owner, path]
+ [owner, path, sessionId]
)
const args = useMemo(
diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts
index f85725e1a3..bb69f6cf3e 100644
--- a/apps/desktop/src/global.d.ts
+++ b/apps/desktop/src/global.d.ts
@@ -350,6 +350,8 @@ declare global {
connectionId?: null | string
path: string
profile?: null | string
+ /** The chat the clip belongs to: its `/workspace` is that session's sandbox. */
+ sessionId?: string
}) => Promise
saveImageFromUrl: (url: string) => Promise
/** Edit verb against the window's focused element (the custom context
diff --git a/apps/desktop/src/hooks/use-media-playback-src.ts b/apps/desktop/src/hooks/use-media-playback-src.ts
index ece3b29bb6..1db092ec94 100644
--- a/apps/desktop/src/hooks/use-media-playback-src.ts
+++ b/apps/desktop/src/hooks/use-media-playback-src.ts
@@ -1,6 +1,8 @@
+import { useStore } from '@nanostores/react'
import { type SyntheticEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useComposerScope } from '@/app/chat/composer/scope'
+import { useSessionView } from '@/app/chat/session-view'
import { mediaKind, resolveMediaPlaybackSrc } from '@/lib/media'
type PlaybackEvent = SyntheticEvent
@@ -31,6 +33,12 @@ export function useMediaPlaybackSrc(path: string) {
[scope.connectionId, scope.profile]
)
+ // The transcript's own session: a Docker agent's /workspace clip lives in its sandbox.
+ const view = useSessionView()
+ const storedId = useStore(view.$storedId)
+ const runtimeId = useStore(view.$runtimeId)
+ const sessionId = storedId || runtimeId || undefined
+
const [media, setMedia] = useState({ failed: false, path, src: '' })
const loadedSrc = useRef('')
// Minted for a source that failed before it ever loaded; it gets no retry of its own.
@@ -47,7 +55,7 @@ export function useMediaPlaybackSrc(path: string) {
return
}
- void resolveMediaPlaybackSrc(path, owner)
+ void resolveMediaPlaybackSrc(path, owner, sessionId)
.then(value => {
if (value.startsWith('blob:')) {
objectUrl = value
@@ -72,7 +80,7 @@ export function useMediaPlaybackSrc(path: string) {
URL.revokeObjectURL(objectUrl)
}
}
- }, [kind, owner, path])
+ }, [kind, owner, path, sessionId])
const onLoadedMetadata = useCallback((event: PlaybackEvent) => {
const element = event.currentTarget
@@ -115,7 +123,7 @@ export function useMediaPlaybackSrc(path: string) {
const point = { element, paused: element.paused, time: element.currentTime }
- void resolveMediaPlaybackSrc(path, owner)
+ void resolveMediaPlaybackSrc(path, owner, sessionId)
.then(fresh => {
if (fresh === failing || fresh.startsWith('blob:')) {
if (fresh !== failing) {
@@ -136,7 +144,7 @@ export function useMediaPlaybackSrc(path: string) {
})
.catch(fail)
},
- [owner, path]
+ [owner, path, sessionId]
)
// An element with preload="none" fetches nothing for a new source until asked, so one
diff --git a/apps/desktop/src/lib/browser-files.ts b/apps/desktop/src/lib/browser-files.ts
index 0bf0a44f67..13c12ec877 100644
--- a/apps/desktop/src/lib/browser-files.ts
+++ b/apps/desktop/src/lib/browser-files.ts
@@ -74,7 +74,8 @@ export function createBrowserFilesBridge({
return (await fileEndpointUrl(bootstrap, 'stream', {
path: payload.path,
- profile: payload.profile ?? currentProfile()
+ profile: payload.profile ?? currentProfile(),
+ session_id: payload.sessionId
})).href
},
readDir: (path: string) =>
diff --git a/apps/desktop/src/lib/browser-session-workspace-files.test.tsx b/apps/desktop/src/lib/browser-session-workspace-files.test.tsx
new file mode 100644
index 0000000000..2ba2b2cf72
--- /dev/null
+++ b/apps/desktop/src/lib/browser-session-workspace-files.test.tsx
@@ -0,0 +1,90 @@
+import { LOCAL_CONNECTION_ID } from '@hermes/shared'
+import { cleanup, render, waitFor } from '@testing-library/react'
+import { atom } from 'nanostores'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { setApiRequestConnection, setApiRequestProfile } from '@/api/client'
+import { SessionViewProvider } from '@/app/chat/session-view'
+import { MarkdownImage } from '@/components/assistant-ui/markdown-text'
+import { $connection } from '@/store/session'
+import { clearWebappSession, seedWebappSession } from '@/test/webapp-session'
+
+import { installBrowserDesktopBridge } from './browser-desktop-bridge'
+import { resolveMediaPlaybackSrc } from './media'
+
+// WA-5: a Docker agent's /workspace is its own session's sandbox, so a Webapp file request names
+// the session it belongs to. The gateway half is tests/hermes_cli/test_web_server_fs_session_workspace.py.
+const OWNER = { connectionId: LOCAL_CONNECTION_ID, profile: 'default' }
+const CLIP = '/workspace/clip.mp4'
+const win = window
+let requests: { body: Record; method: string; url: URL }[] = []
+
+beforeEach(() => {
+ requests = []
+ seedWebappSession('served-token')
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: URL, init?: RequestInit) => {
+ requests.push({ body: JSON.parse(String(init?.body ?? '{}')), method: init?.method ?? 'GET', url: input })
+
+ const answers: Record = {
+ '/api/files/ticket': { ticket: 'stream-ticket' },
+ '/api/fs/read-text': { path: '/workspace/note.txt', text: 'session note' },
+ '/api/fs/write-text': { ok: true, path: '/workspace/note.txt' }
+ }
+
+ return new Response(JSON.stringify(answers[input.pathname] ?? {}), { status: 200 })
+ })
+ )
+ expect(installBrowserDesktopBridge()).toBe(true)
+ setApiRequestConnection(LOCAL_CONNECTION_ID)
+ setApiRequestProfile('default')
+ $connection.set({ connectionId: LOCAL_CONNECTION_ID, mode: 'remote', profile: 'default' } as never)
+})
+
+afterEach(() => {
+ cleanup()
+ clearWebappSession()
+ Reflect.deleteProperty(win, 'hermesDesktop')
+ document.documentElement.removeAttribute('data-hermes-desktop-host')
+ setApiRequestConnection(null)
+ setApiRequestProfile(null)
+ $connection.set(null)
+ window.dispatchEvent(new Event('beforeunload'))
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+})
+
+describe('media in a session sandbox', () => {
+ it('tickets a clip for the session it belongs to', async () => {
+ const src = new URL(await resolveMediaPlaybackSrc(CLIP, OWNER, 'sess-1'))
+ const [ticket] = requests
+
+ expect(ticket.body).toEqual({ path: CLIP, profile: 'default', route: 'stream', session_id: 'sess-1' })
+ expect(Object.fromEntries(src.searchParams)).toEqual({
+ path: CLIP,
+ profile: 'default',
+ session_id: 'sess-1',
+ ticket: 'stream-ticket'
+ })
+ })
+
+ it('names no session when the caller has none', async () => {
+ await resolveMediaPlaybackSrc(CLIP, OWNER)
+
+ expect(requests[0].body).toEqual({ path: CLIP, profile: 'default', route: 'stream' })
+ })
+
+ it('plays a transcript clip from the transcript session', async () => {
+ const view = { $runtimeId: atom('runtime-1'), $storedId: atom('stored-1') }
+
+ const { container } = render(
+
+
+
+ )
+
+ await waitFor(() => expect(container.querySelector('video')?.getAttribute('src')).toContain('ticket='))
+ expect(requests[0].body).toMatchObject({ path: CLIP, route: 'stream', session_id: 'stored-1' })
+ })
+})
diff --git a/apps/desktop/src/lib/media.ts b/apps/desktop/src/lib/media.ts
index ccf10f0ec8..f2b1fb8c32 100644
--- a/apps/desktop/src/lib/media.ts
+++ b/apps/desktop/src/lib/media.ts
@@ -239,7 +239,7 @@ export function rememberMediaImageFailure(key: string): void {
// Audio/video need a seekable source instead of a whole-file data URL. Keep
// remote URLs untouched. The Webapp supplies an authenticated HTTP source;
// Electron's media protocol reads local files or proxies remote gateways.
-export async function resolveMediaPlaybackSrc(path: string, owner?: OwnerScope): Promise {
+export async function resolveMediaPlaybackSrc(path: string, owner?: OwnerScope, sessionId?: string): Promise {
if (isInlineMediaSrc(path)) {
return path
}
@@ -253,7 +253,9 @@ export async function resolveMediaPlaybackSrc(path: string, owner?: OwnerScope):
connectionId: owner?.connectionId || conn?.connectionId,
// The gateway OS owns file-URI conversion, including drive letters and UNC hosts.
path,
- profile: owner?.profile || conn?.profile
+ profile: owner?.profile || conn?.profile,
+ // A Docker agent's /workspace is its session's own sandbox.
+ ...(sessionId ? { sessionId } : {})
})
}
diff --git a/hermes_cli/web_routers/files.py b/hermes_cli/web_routers/files.py
index 2ee8edf6fa..c669a86107 100644
--- a/hermes_cli/web_routers/files.py
+++ b/hermes_cli/web_routers/files.py
@@ -812,14 +812,19 @@ async def download_managed_file(request: Request, path: str):
@router.get("/api/files/stream")
@router.head("/api/files/stream")
-async def stream_managed_file(request: Request, path: str, profile: Optional[str] = None):
+async def stream_managed_file(
+ request: Request, path: str, profile: Optional[str] = None, session_id: Optional[str] = None,
+):
"""Stream managed audio/video inline with HTTP Range support — Electron's
media pipeline may reject an attachment response as an ``
)
diff --git a/apps/desktop/src/plugins/hermes-bots/screen-viewer-identity.test.tsx b/apps/desktop/src/plugins/hermes-bots/screen-viewer-identity.test.tsx
index 61787b5937..dbf68efb27 100644
--- a/apps/desktop/src/plugins/hermes-bots/screen-viewer-identity.test.tsx
+++ b/apps/desktop/src/plugins/hermes-bots/screen-viewer-identity.test.tsx
@@ -43,7 +43,8 @@ vi.mock('./i18n', () => ({
handBackForceHint: 'Force',
takeOver: 'Take over',
reconnect: 'Reconnect',
- streamLost: 'Stream lost'
+ streamLost: 'Stream lost',
+ needsHttps: 'Needs HTTPS'
}
})
}))
@@ -175,3 +176,18 @@ it('does not offer Take over while no server-minted viewer id exists, even once
await waitFor(() => expect(view.getByText('Take over').closest('button')?.disabled).toBe(false))
view.unmount()
})
+
+it('says the screen needs HTTPS where an insecure origin has no crypto.subtle, before asking for a viewer', async () => {
+ Object.defineProperty(globalThis.crypto, 'subtle', { configurable: true, value: undefined })
+
+ try {
+ const view = render()
+ await waitFor(() => expect(vi.mocked(displayRequest)).toHaveBeenCalledWith(bot, 'display.status'))
+ await act(async () => {})
+ expect(view.getByText('Needs HTTPS')).toBeTruthy()
+ expect(vi.mocked(displayRequest)).not.toHaveBeenCalledWith(bot, 'display.observe', expect.anything())
+ view.unmount()
+ } finally {
+ Reflect.deleteProperty(globalThis.crypto, 'subtle')
+ }
+})
From 28dd19ff7b9d21fc4e1e9c24a46837aac1f4508b Mon Sep 17 00:00:00 2001
From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com>
Date: Thu, 22 Oct 2026 12:24:52 +0300
Subject: [PATCH 108/114] fix(webapp): report "microphone unsupported" for live
voice on plain-HTTP origins
Insecure contexts expose no navigator.mediaDevices. Dictation guards
for that and says "This runtime does not support microphone recording",
but GPT-Live voice conversation called getUserMedia unguarded after
opening a peer connection, so the toast showed a raw "Cannot read
properties of undefined (reading 'getUserMedia')".
Fail before opening the peer with a NotSupportedError, which micError
now maps to the same "microphone unsupported" copy. Barge-in gets the
same guard: its monitor no longer reaches for the missing API (its
catch had swallowed the TypeError, so it was silently inert before).
Refs #93508 (review P3)
(cherry picked from commit da5e6f68394dd921fbffbc7150403b19af501ac3)
---
.../chat/composer/hooks/use-mic-recorder.ts | 4 ++
apps/desktop/src/lib/voice-barge-in.ts | 6 +++
apps/desktop/src/lib/voice-live.ts | 7 ++++
.../lib/voice-microphone-unsupported.test.ts | 37 +++++++++++++++++++
4 files changed, 54 insertions(+)
create mode 100644 apps/desktop/src/lib/voice-microphone-unsupported.test.ts
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts
index 9ad6b38c54..566751e31d 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts
@@ -68,6 +68,10 @@ export function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
return new Error(copy.microphoneConstraintsUnsupported)
}
+ if (name === 'NotSupportedError') {
+ return new Error(copy.microphoneUnsupported)
+ }
+
if (error instanceof DOMException) {
return new Error(copy.microphoneStartFailed)
}
diff --git a/apps/desktop/src/lib/voice-barge-in.ts b/apps/desktop/src/lib/voice-barge-in.ts
index 22d5582f10..2098885d88 100644
--- a/apps/desktop/src/lib/voice-barge-in.ts
+++ b/apps/desktop/src/lib/voice-barge-in.ts
@@ -192,6 +192,12 @@ export function monitorSpeechDuringPlayback(callbacks: BargeMonitorCallbacks): (
active.stop()
}
+
+ // No mediaDevices (an insecure origin): there is no mic to monitor. The conversation's own
+ // listening start already reports that, through the recorder's "microphone unsupported" copy.
+ if (!navigator.mediaDevices?.getUserMedia) {
+ return cleanup
+ }
void (async () => {
try {
// Don't open a second capture context while the recorder's is still
diff --git a/apps/desktop/src/lib/voice-live.ts b/apps/desktop/src/lib/voice-live.ts
index 2352e19b6f..6bd94066c8 100644
--- a/apps/desktop/src/lib/voice-live.ts
+++ b/apps/desktop/src/lib/voice-live.ts
@@ -280,6 +280,13 @@ export class VoiceLiveSession {
throw new Error('GPT-Live session already started')
}
+ // An insecure origin (a Webapp opened over plain HTTP from another machine) has no
+ // mediaDevices. Fail before opening a peer, with the error the recorder's copy maps to
+ // "microphone unsupported", instead of a TypeError on the missing API.
+ if (!navigator.mediaDevices?.getUserMedia) {
+ throw new DOMException('Microphone capture is unavailable here', 'NotSupportedError')
+ }
+
const connection = new RTCPeerConnection()
this.peer = connection
diff --git a/apps/desktop/src/lib/voice-microphone-unsupported.test.ts b/apps/desktop/src/lib/voice-microphone-unsupported.test.ts
new file mode 100644
index 0000000000..d24368a135
--- /dev/null
+++ b/apps/desktop/src/lib/voice-microphone-unsupported.test.ts
@@ -0,0 +1,37 @@
+import { afterEach, expect, it, vi } from 'vitest'
+
+import { micError } from '@/app/chat/composer/hooks/use-mic-recorder'
+import { en } from '@/i18n/en'
+
+import { monitorSpeechDuringPlayback } from './voice-barge-in'
+import { VoiceLiveSession } from './voice-live'
+
+// A Webapp opened over plain HTTP from another machine has no navigator.mediaDevices; nor has jsdom.
+afterEach(() => vi.unstubAllGlobals())
+
+it('live voice fails with "microphone unsupported" without mediaDevices, before opening a peer', async () => {
+ expect(navigator.mediaDevices).toBeUndefined()
+ const peers = vi.fn()
+
+ vi.stubGlobal('RTCPeerConnection', class {
+ constructor() { peers() }
+ addEventListener() {}
+ close() {}
+ })
+
+ const session = new VoiceLiveSession({ onClosed: vi.fn(), onDelegation: vi.fn(), onError: vi.fn() })
+ const failure = await session.start([]).catch((error: unknown) => error)
+
+ expect(micError(failure, en.notifications.voice).message).toBe(en.notifications.voice.microphoneUnsupported)
+ expect(peers).not.toHaveBeenCalled()
+})
+
+it('barge-in never reaches for a microphone it cannot have', () => {
+ const audioContext = vi.fn()
+ vi.stubGlobal('AudioContext', audioContext)
+
+ const stop = monitorSpeechDuringPlayback({ onSpeech: vi.fn(), onUtterance: vi.fn() })
+
+ expect(audioContext).not.toHaveBeenCalled()
+ stop()
+})
From 477937cfb899c752316f5229792c62260f623f2f Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Sun, 25 Oct 2026 22:42:09 +0000
Subject: [PATCH 109/114] fix(webapp): say what plain HTTP loses when the
Webapp's address is not secure
Browsers keep secure-context APIs from a plain-HTTP origin that is not
loopback: crypto.subtle (the bot screen viewer), mediaDevices (dictation
and live voice) and Notification (system notifications). Nothing told
the user before they hit the missing feature.
hermes webapp and hermes webapp --status now print a note naming those
features and the way to keep them (HTTPS through webapp.public_url, or
an SSH tunnel to 127.0.0.1) when the address they open or print is
plain HTTP on a host other than localhost, *.localhost, 127.0.0.0/8 or
::1, which browsers already treat as secure. The Webapp docs list the
same features.
Refs #144648 (WA-14)
---
hermes_cli/webapp_launcher.py | 26 ++++++++++++++++
tests/hermes_cli/test_webapp_launcher.py | 39 ++++++++++++++++++++++++
website/docs/user-guide/desktop.md | 15 ++++++---
3 files changed, 76 insertions(+), 4 deletions(-)
diff --git a/hermes_cli/webapp_launcher.py b/hermes_cli/webapp_launcher.py
index c9c47b8b84..b4b3c40370 100644
--- a/hermes_cli/webapp_launcher.py
+++ b/hermes_cli/webapp_launcher.py
@@ -15,12 +15,14 @@ link for the user to open instead.
from __future__ import annotations
import html
+import ipaddress
import json
import os
import secrets
import sys
import time
from pathlib import Path
+from urllib.parse import urlsplit
RESTART_COMMAND = "hermes gateway restart"
# The gateway reads the Webapp's settings and sign-in providers only when it starts.
@@ -30,6 +32,11 @@ STARTED_BEFORE_ON = f"The gateway started before the Webapp was turned on. Resta
# socket answers, so the gateway running predates them (an upgrade it has not restarted onto).
OLDER_GATEWAY = ("The running gateway is older than the Webapp and does not know its commands. Restart it "
f"onto this version of Hermes: {RESTART_COMMAND}")
+# Browsers keep secure-context APIs (crypto.subtle, mediaDevices, Notification) from a plain-HTTP origin
+# that is not loopback.
+PLAIN_HTTP_NOTE = ("This address is plain HTTP, so the browser turns off what needs a secure connection: the bot "
+ "screen, the microphone (dictation and live voice) and system notifications. Open the Webapp "
+ "over HTTPS (webapp.public_url) or through an SSH tunnel to 127.0.0.1 to use them.")
# How long --sign-out waits for a gateway that is still starting to answer (30 s).
_START_WAIT_ATTEMPTS, _START_WAIT_INTERVAL = 60, 0.5
@@ -181,6 +188,21 @@ def _off_message(target: Path, then: str) -> str:
return f"The Webapp is off. Set webapp.enabled: true in {target / 'config.yaml'}, then {then}."
+def _plain_http_note(url: str) -> str | None:
+ """:data:`PLAIN_HTTP_NOTE` when *url* is plain HTTP on a host other than loopback, else None.
+ Browsers count ``localhost``, ``*.localhost``, 127.0.0.0/8 and ``::1`` as secure contexts."""
+ parts = urlsplit(url)
+ if parts.scheme != "http":
+ return None
+ host = (parts.hostname or "").rstrip(".")
+ if host == "localhost" or host.endswith(".localhost"):
+ return None
+ try:
+ return None if ipaddress.ip_address(host).is_loopback else PLAIN_HTTP_NOTE
+ except ValueError:
+ return PLAIN_HTTP_NOTE
+
+
def _status(target: Path) -> int:
try:
response = _control(target, "webapp-status")
@@ -197,6 +219,8 @@ def _status(target: Path) -> int:
status = response["result"]
if status["state"] == "serving":
print(f"Serving at {status['url']}")
+ if note := _plain_http_note(status["url"]):
+ print(note)
from gateway.webapp_listener import webapp_settings
if not webapp_settings(target).enabled:
print(f"{target / 'config.yaml'} no longer turns the Webapp on: it stops at the next gateway restart "
@@ -345,6 +369,8 @@ def cmd_webapp(args) -> int:
if not response.get("ok"):
return _explain_refused_launch(target, response)
link = response["result"]
+ if note := _plain_http_note(link["url"]):
+ print(note)
if link.get("sign_in"):
# The Webapp is behind the dashboard's sign-in: the page asks for it, so its address is no secret.
if not args.no_open and _open(link["url"]):
diff --git a/tests/hermes_cli/test_webapp_launcher.py b/tests/hermes_cli/test_webapp_launcher.py
index 367868d27b..c2a51ca806 100644
--- a/tests/hermes_cli/test_webapp_launcher.py
+++ b/tests/hermes_cli/test_webapp_launcher.py
@@ -426,3 +426,42 @@ def test_a_link_for_the_user_to_open_by_hand_is_printed_and_never_handed_to_a_br
assert all("ticket" not in url for url in world["calls"]["opened"])
out = capsys.readouterr().out
assert LINK in out and "keep it private" in out and "60 seconds" in out and "once" in out
+
+
+def _says_what_plain_http_loses(out: str) -> bool:
+ lowered = out.lower()
+ return all(part in lowered for part in ("plain http", "bot screen", "microphone", "notifications", "https"))
+
+
+@pytest.mark.parametrize("no_open", [False, True])
+def test_a_webapp_opened_over_plain_http_on_the_lan_says_what_the_browser_loses(world, capsys, no_open):
+ """Browsers keep secure-context features (the bot screen's viewer, the microphone, notifications)
+ from a plain-HTTP origin that is not loopback: the launcher says so wherever it opens or prints one."""
+ url = "http://192.168.1.20:9120/"
+ world["answers"]["webapp-launch"] = {"ok": True, "result": {
+ "url": url, "private": False, "expires_in_seconds": None, "sign_in": True}}
+ assert launcher.cmd_webapp(_args(no_open=no_open)) == 0
+ assert _says_what_plain_http_loses(capsys.readouterr().out)
+
+
+def test_status_of_a_webapp_served_over_plain_http_on_the_lan_says_what_the_browser_loses(world, capsys):
+ world["answers"]["webapp-status"] = {"ok": True, "result": {
+ "state": "serving", "url": "http://10.0.1.170:9120", "reason": None}}
+ assert launcher.cmd_webapp(_args(status=True)) == 0
+ assert _says_what_plain_http_loses(capsys.readouterr().out)
+
+
+@pytest.mark.parametrize("url", ["http://127.0.0.1:9120/", "http://127.0.0.2:9120/", "http://localhost:9120/",
+ "http://hermes.localhost:9120/", "http://[::1]:9120/",
+ "https://hermes.example.ts.net/"])
+def test_loopback_and_https_addresses_are_secure_contexts_and_get_no_warning(world, capsys, url):
+ world["answers"]["webapp-launch"] = {"ok": True, "result": {
+ "url": url, "private": False, "expires_in_seconds": None, "sign_in": True}}
+ assert launcher.cmd_webapp(_args(no_open=True)) == 0
+ assert "plain http" not in capsys.readouterr().out.lower()
+
+
+def test_a_one_use_link_on_loopback_gets_no_plain_http_warning(world, capsys):
+ assert launcher.cmd_webapp(_args(no_open=True)) == 0
+ assert launcher.cmd_webapp(_args(status=True)) == 0
+ assert "plain http" not in capsys.readouterr().out.lower()
diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md
index 118e98e575..edd02cded4 100644
--- a/website/docs/user-guide/desktop.md
+++ b/website/docs/user-guide/desktop.md
@@ -79,8 +79,14 @@ requires the same sign-in. `hermes webapp` then opens the Webapp's address, wher
your dashboard account: `webapp.public_url` when set; on the LAN, the address **Web Access → Start**
registered Nous sign-in for, while it is still this machine's address and the Webapp's port; else the
loopback address (a password provider, or sign-in you set up by hand for that address). One-use
-links serve only a Webapp that needs no sign-in. HTTPS is strongly recommended remotely and browsers
-require it for some microphone and clipboard features.
+links serve only a Webapp that needs no sign-in. HTTPS is strongly recommended remotely.
+
+**What plain HTTP loses.** Browsers turn off features that need a secure context on a plain-HTTP
+address other than loopback (`http://localhost` and `http://127.0.0.1` count as secure): the bot
+screen viewer, the microphone (dictation and live voice) and system notifications. The bot screen and
+live voice say so instead of opening. `hermes webapp` and `hermes webapp --status` print a note when
+the address they give is plain HTTP on another host. To keep these features, open the Webapp over
+HTTPS (`webapp.public_url`) or through an SSH tunnel to `127.0.0.1`.
:::warning Treat Webapp access like access to Hermes on the host
A signed-in Webapp user can run arbitrary commands as the OS account running Hermes, run agent tools,
@@ -118,8 +124,9 @@ and only that app can change it: a browser signed in over the network cannot.
- **This network:** devices on the same Wi-Fi open `http://:`, where the
port is `webapp.port` (9120 unless you set another, or Start chose one for a standalone profile).
- This is plain HTTP: the connection is not encrypted, so others on the network could read it. Use it
- only on a network you trust.
+ This is plain HTTP: the connection is not encrypted, so others on the network could read it, and
+ browsers turn off the features listed under **What plain HTTP loses** above. Use it only on a
+ network you trust.
- **My own URL:** an HTTPS address you already run (Tailscale Serve, a tunnel or a reverse proxy) that
forwards to `http://127.0.0.1:`. It is saved as `webapp.public_url`. Never forward that port
from your router directly.
From c7bf749b7b9b5d46e3fd27ba16907f9239b7e0e6 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 02:46:34 +0000
Subject: [PATCH 110/114] fix(webapp): warn in --status about the plain-HTTP
address browsers open
The gateway reports its loopback dial origin, so the --status note never fired. Work out the
browser-facing address as webapp-launch does (webapp.public_url, else the LAN address Web Access
registered sign-in for) and print the note for that address.
---
hermes_cli/webapp_launcher.py | 16 ++++++++--
tests/hermes_cli/test_webapp_launcher.py | 37 +++++++++++++++++++++---
website/docs/user-guide/desktop.md | 3 +-
3 files changed, 48 insertions(+), 8 deletions(-)
diff --git a/hermes_cli/webapp_launcher.py b/hermes_cli/webapp_launcher.py
index b4b3c40370..d0146e2344 100644
--- a/hermes_cli/webapp_launcher.py
+++ b/hermes_cli/webapp_launcher.py
@@ -203,6 +203,15 @@ def _plain_http_note(url: str) -> str | None:
return PLAIN_HTTP_NOTE
+def _browser_address(target: Path, settings, served_url: str) -> str:
+ """Where a browser opens the Webapp the gateway serves at *served_url* (its loopback dial origin), as
+ ``webapp-launch`` works it out behind sign-in: ``webapp.public_url`` when set, else the LAN address
+ Web Access registered Nous sign-in for, else *served_url*. Read from *target*'s config now, not from
+ when the gateway started."""
+ from hermes_cli.webapp_access import registered_lan_origin
+ return settings.public_url or registered_lan_origin(target, urlsplit(served_url).port) or served_url
+
+
def _status(target: Path) -> int:
try:
response = _control(target, "webapp-status")
@@ -219,10 +228,11 @@ def _status(target: Path) -> int:
status = response["result"]
if status["state"] == "serving":
print(f"Serving at {status['url']}")
- if note := _plain_http_note(status["url"]):
- print(note)
from gateway.webapp_listener import webapp_settings
- if not webapp_settings(target).enabled:
+ settings = webapp_settings(target)
+ if note := _plain_http_note(_browser_address(target, settings, status["url"])):
+ print(note)
+ if not settings.enabled:
print(f"{target / 'config.yaml'} no longer turns the Webapp on: it stops at the next gateway restart "
f"({RESTART_COMMAND}).")
elif status["state"] == "starting":
diff --git a/tests/hermes_cli/test_webapp_launcher.py b/tests/hermes_cli/test_webapp_launcher.py
index c2a51ca806..0ba0aaceb6 100644
--- a/tests/hermes_cli/test_webapp_launcher.py
+++ b/tests/hermes_cli/test_webapp_launcher.py
@@ -6,6 +6,7 @@ over the control socket and opens it. It never serves anything itself. ``--sign-
the gateway did, waits for a gateway that is still starting, and replaces the stored credential
itself only when no gateway runs.
"""
+import json
from argparse import Namespace
from pathlib import Path
@@ -444,11 +445,39 @@ def test_a_webapp_opened_over_plain_http_on_the_lan_says_what_the_browser_loses(
assert _says_what_plain_http_loses(capsys.readouterr().out)
-def test_status_of_a_webapp_served_over_plain_http_on_the_lan_says_what_the_browser_loses(world, capsys):
- world["answers"]["webapp-status"] = {"ok": True, "result": {
- "state": "serving", "url": "http://10.0.1.170:9120", "reason": None}}
+def _register_lan_sign_in(world, address: str, port: int = 9120) -> None:
+ """Web Access Start registered Nous sign-in for ``http://:``, this machine's LAN address."""
+ from hermes_cli import webapp_access
+ record = world["root"] / "webapp" / "sign-in-registration.json"
+ record.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
+ record.write_text(json.dumps({"portal": "https://portal.example", "client_id": "agent:x",
+ "callback": f"http://{address}:{port}/auth/callback"}) + "\n", encoding="utf-8")
+ world["monkeypatch"].setattr(webapp_access, "lan_address", lambda: address)
+
+
+@pytest.mark.parametrize("browser_address", ["public_url", "lan_sign_in"])
+def test_status_of_a_webapp_browsers_open_over_plain_http_on_the_lan_says_what_the_browser_loses(
+ world, capsys, browser_address):
+ """The gateway reports its loopback dial origin; browsers open webapp.public_url, else the LAN address
+ Web Access registered sign-in for, as hermes webapp does. --status warns about that address."""
+ if browser_address == "public_url":
+ _home(world["root"], "webapp:\n enabled: true\n host: 0.0.0.0\n public_url: http://192.168.1.20:9120\n")
+ else:
+ _home(world["root"], "webapp:\n enabled: true\n host: 0.0.0.0\n")
+ _register_lan_sign_in(world, "192.168.1.20")
assert launcher.cmd_webapp(_args(status=True)) == 0
- assert _says_what_plain_http_loses(capsys.readouterr().out)
+ out = capsys.readouterr().out
+ assert "Serving at http://127.0.0.1:9120" in out
+ assert _says_what_plain_http_loses(out)
+
+
+@pytest.mark.parametrize("public_url", ["", "https://hermes.example.ts.net"])
+def test_status_gets_no_plain_http_warning_when_browsers_open_a_secure_address(world, capsys, public_url):
+ """A wildcard bind with no public URL or LAN sign-in opens loopback; an HTTPS public URL is secure."""
+ _home(world["root"], "webapp:\n enabled: true\n host: 0.0.0.0\n"
+ + (f" public_url: {public_url}\n" if public_url else ""))
+ assert launcher.cmd_webapp(_args(status=True)) == 0
+ assert "plain http" not in capsys.readouterr().out.lower()
@pytest.mark.parametrize("url", ["http://127.0.0.1:9120/", "http://127.0.0.2:9120/", "http://localhost:9120/",
diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md
index edd02cded4..9fca2edd58 100644
--- a/website/docs/user-guide/desktop.md
+++ b/website/docs/user-guide/desktop.md
@@ -85,7 +85,8 @@ links serve only a Webapp that needs no sign-in. HTTPS is strongly recommended r
address other than loopback (`http://localhost` and `http://127.0.0.1` count as secure): the bot
screen viewer, the microphone (dictation and live voice) and system notifications. The bot screen and
live voice say so instead of opening. `hermes webapp` and `hermes webapp --status` print a note when
-the address they give is plain HTTP on another host. To keep these features, open the Webapp over
+the address a browser opens (`webapp.public_url`, else the LAN address **Start** registered sign-in
+for) is plain HTTP on another host. To keep these features, open the Webapp over
HTTPS (`webapp.public_url`) or through an SSH tunnel to `127.0.0.1`.
:::warning Treat Webapp access like access to Hermes on the host
From 86fa3afdf433afd972ec22519b16af167e727a87 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 02:47:05 +0000
Subject: [PATCH 111/114] fix(webapp): list clipboard reads and the wake word
among what plain HTTP loses
navigator.clipboard is undefined on a plain-HTTP origin other than loopback, so the browser terminal pastes nothing, its right-click Paste stays disabled and pasting an image finds no clipboard image. The remote wake word uses getUserMedia like dictation and live voice.
---
hermes_cli/webapp_launcher.py | 9 +++++----
tests/hermes_cli/test_webapp_launcher.py | 3 ++-
website/docs/user-guide/desktop.md | 3 ++-
3 files changed, 9 insertions(+), 6 deletions(-)
diff --git a/hermes_cli/webapp_launcher.py b/hermes_cli/webapp_launcher.py
index d0146e2344..f542801901 100644
--- a/hermes_cli/webapp_launcher.py
+++ b/hermes_cli/webapp_launcher.py
@@ -32,11 +32,12 @@ STARTED_BEFORE_ON = f"The gateway started before the Webapp was turned on. Resta
# socket answers, so the gateway running predates them (an upgrade it has not restarted onto).
OLDER_GATEWAY = ("The running gateway is older than the Webapp and does not know its commands. Restart it "
f"onto this version of Hermes: {RESTART_COMMAND}")
-# Browsers keep secure-context APIs (crypto.subtle, mediaDevices, Notification) from a plain-HTTP origin
-# that is not loopback.
+# Browsers keep secure-context APIs (crypto.subtle, mediaDevices, navigator.clipboard, Notification) from a
+# plain-HTTP origin that is not loopback.
PLAIN_HTTP_NOTE = ("This address is plain HTTP, so the browser turns off what needs a secure connection: the bot "
- "screen, the microphone (dictation and live voice) and system notifications. Open the Webapp "
- "over HTTPS (webapp.public_url) or through an SSH tunnel to 127.0.0.1 to use them.")
+ "screen, the microphone (dictation, live voice and the remote wake word), reading the clipboard "
+ "(terminal paste, pasting an image) and system notifications. Open the Webapp over HTTPS "
+ "(webapp.public_url) or through an SSH tunnel to 127.0.0.1 to use them.")
# How long --sign-out waits for a gateway that is still starting to answer (30 s).
_START_WAIT_ATTEMPTS, _START_WAIT_INTERVAL = 60, 0.5
diff --git a/tests/hermes_cli/test_webapp_launcher.py b/tests/hermes_cli/test_webapp_launcher.py
index 0ba0aaceb6..5118ad1964 100644
--- a/tests/hermes_cli/test_webapp_launcher.py
+++ b/tests/hermes_cli/test_webapp_launcher.py
@@ -431,7 +431,8 @@ def test_a_link_for_the_user_to_open_by_hand_is_printed_and_never_handed_to_a_br
def _says_what_plain_http_loses(out: str) -> bool:
lowered = out.lower()
- return all(part in lowered for part in ("plain http", "bot screen", "microphone", "notifications", "https"))
+ return all(part in lowered for part in ("plain http", "bot screen", "microphone", "wake word",
+ "reading the clipboard", "notifications", "https"))
@pytest.mark.parametrize("no_open", [False, True])
diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md
index 9fca2edd58..c5905fd0cf 100644
--- a/website/docs/user-guide/desktop.md
+++ b/website/docs/user-guide/desktop.md
@@ -83,7 +83,8 @@ links serve only a Webapp that needs no sign-in. HTTPS is strongly recommended r
**What plain HTTP loses.** Browsers turn off features that need a secure context on a plain-HTTP
address other than loopback (`http://localhost` and `http://127.0.0.1` count as secure): the bot
-screen viewer, the microphone (dictation and live voice) and system notifications. The bot screen and
+screen viewer, the microphone (dictation, live voice and the remote wake word), reading the clipboard
+(terminal paste, pasting an image) and system notifications. The bot screen and
live voice say so instead of opening. `hermes webapp` and `hermes webapp --status` print a note when
the address a browser opens (`webapp.public_url`, else the LAN address **Start** registered sign-in
for) is plain HTTP on another host. To keep these features, open the Webapp over
From 1c67fed9753b7bcfd51f850cc3e3dd81f405de30 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 02:50:07 +0000
Subject: [PATCH 112/114] fix(webapp): say HTTPS fixes the microphone on a
plain-HTTP Webapp
On an insecure origin the Webapp has no mediaDevices, so dictation and live voice reported the generic "This runtime does not support microphone recording." The Webapp now says to open it over HTTPS or through an SSH tunnel to 127.0.0.1, like the bot screen does. Native Desktop keeps the old text.
---
.../composer/hooks/use-mic-recorder.test.tsx | 1 +
.../chat/composer/hooks/use-mic-recorder.ts | 15 +++++++++++--
apps/desktop/src/i18n/ar_diagnostics.ts | 1 +
apps/desktop/src/i18n/de.ts | 1 +
apps/desktop/src/i18n/en.ts | 1 +
apps/desktop/src/i18n/es.ts | 1 +
apps/desktop/src/i18n/fr.ts | 1 +
apps/desktop/src/i18n/ja.ts | 1 +
apps/desktop/src/i18n/ru.ts | 1 +
apps/desktop/src/i18n/types.ts | 1 +
apps/desktop/src/i18n/zh-hant_diagnostics.ts | 1 +
apps/desktop/src/i18n/zh.ts | 1 +
.../lib/voice-microphone-unsupported.test.ts | 22 ++++++++++++++++++-
locales/_keys.desktop.json | 1 +
14 files changed, 46 insertions(+), 3 deletions(-)
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.test.tsx
index 7e350a6d39..9a2d31e442 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.test.tsx
+++ b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.test.tsx
@@ -12,6 +12,7 @@ const copy: MicRecorderErrorCopy = {
microphoneAccessDenied: 'denied',
microphoneConstraintsUnsupported: 'constraints',
microphoneInUse: 'in use',
+ microphoneNeedsHttps: 'needs https',
microphonePermissionDenied: 'permission',
microphoneStartFailed: 'start failed',
microphoneUnsupported: 'unsupported',
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts
index 566751e31d..5f07e580cf 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { closeMeterContext, meterContextsClosed } from '@/lib/mic-meter-context'
+import { isBrowserHostedDesktop } from '@/lib/platform'
type BrowserAudioContext = typeof AudioContext
@@ -33,6 +34,7 @@ export interface MicRecorderErrorCopy {
microphoneAccessDenied: string
microphoneConstraintsUnsupported: string
microphoneInUse: string
+ microphoneNeedsHttps: string
microphonePermissionDenied: string
microphoneStartFailed: string
microphoneUnsupported: string
@@ -45,6 +47,15 @@ interface MicRecorderHandle {
cancel: () => void
}
+/** "Microphone unsupported" copy. The Webapp opened over plain HTTP from another machine is an
+ * insecure context with no mediaDevices: it says HTTPS or a tunnel fixes that. Native Desktop
+ * keeps the plain copy. */
+function unsupportedMicrophone(copy: MicRecorderErrorCopy): string {
+ return isBrowserHostedDesktop() && window.isSecureContext === false
+ ? copy.microphoneNeedsHttps
+ : copy.microphoneUnsupported
+}
+
/** Recorder + live-start mic failures → the same friendly copy: a DOMException
* name is mapped, an unrecognized DOMException falls back to the generic start
* copy, and anything else keeps its own message (non-mic failures must not be
@@ -69,7 +80,7 @@ export function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
}
if (name === 'NotSupportedError') {
- return new Error(copy.microphoneUnsupported)
+ return new Error(unsupportedMicrophone(copy))
}
if (error instanceof DOMException) {
@@ -272,7 +283,7 @@ export function useMicRecorder(copy: MicRecorderErrorCopy): {
}
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
- throw new Error(copy.microphoneUnsupported)
+ throw new Error(unsupportedMicrophone(copy))
}
const permitted = await window.hermesDesktop?.requestMicrophoneAccess?.()
diff --git a/apps/desktop/src/i18n/ar_diagnostics.ts b/apps/desktop/src/i18n/ar_diagnostics.ts
index 4694eaf93b..5c1f83dd83 100644
--- a/apps/desktop/src/i18n/ar_diagnostics.ts
+++ b/apps/desktop/src/i18n/ar_diagnostics.ts
@@ -73,6 +73,7 @@ export const arDiagnostics = {
microphoneConstraintsUnsupported: 'قيود الميكروفون غير مدعومة على هذا الجهاز.',
microphoneFailed: 'فشل الميكروفون',
microphoneInUse: 'الميكروفون مستخدم من تطبيق آخر.',
+ microphoneNeedsHttps: 'يحتاج الميكروفون إلى اتصال آمن. افتح Webapp عبر HTTPS، أو عبر نفق SSH إلى 127.0.0.1، لاستخدامه.',
microphonePermissionDenied: 'تم رفض إذن الميكروفون.',
microphoneStartFailed: 'تعذر بدء تسجيل الميكروفون.',
microphoneUnsupported: 'هذا المتصفح لا يدعم تسجيل الميكروفون.',
diff --git a/apps/desktop/src/i18n/de.ts b/apps/desktop/src/i18n/de.ts
index b6bc9ed0bb..81ae8f25ad 100644
--- a/apps/desktop/src/i18n/de.ts
+++ b/apps/desktop/src/i18n/de.ts
@@ -464,6 +464,7 @@ export const deOverrides = {
microphoneConstraintsUnsupported: 'Mikrofon-Einschränkungen werden von diesem Gerät nicht unterstützt.',
microphoneFailed: 'Mikrofon fehlgeschlagen',
microphoneInUse: 'Das Mikrofon wird bereits von einer anderen App verwendet.',
+ microphoneNeedsHttps: 'Das Mikrofon braucht eine sichere Verbindung. Öffne die Webapp über HTTPS oder über einen SSH-Tunnel zu 127.0.0.1, um es zu nutzen.',
microphonePermissionDenied: 'Die Mikrofonberechtigung wurde verweigert.',
microphoneStartFailed: 'Mikrofonaufnahme konnte nicht gestartet werden.',
microphoneUnsupported: 'Diese Laufzeitumgebung unterstützt keine Mikrofonaufnahme.',
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 8a0e971420..e8d90bbcff 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -454,6 +454,7 @@ export const en: Translations = {
microphoneConstraintsUnsupported: 'Microphone constraints are not supported by this device.',
microphoneFailed: 'Microphone failed',
microphoneInUse: 'Microphone is already in use by another app.',
+ microphoneNeedsHttps: 'The microphone needs a secure connection. Open the Webapp over HTTPS, or through an SSH tunnel to 127.0.0.1, to use it.',
microphonePermissionDenied: 'Microphone permission was denied.',
microphoneStartFailed: 'Could not start microphone recording.',
microphoneUnsupported: 'This runtime does not support microphone recording.',
diff --git a/apps/desktop/src/i18n/es.ts b/apps/desktop/src/i18n/es.ts
index 7f4de6d8c1..57e1a495bd 100644
--- a/apps/desktop/src/i18n/es.ts
+++ b/apps/desktop/src/i18n/es.ts
@@ -467,6 +467,7 @@ export const esOverrides = {
microphoneConstraintsUnsupported: 'Este dispositivo no admite las restricciones del micrófono.',
microphoneFailed: 'Falló el micrófono',
microphoneInUse: 'El micrófono ya está en uso por otra app.',
+ microphoneNeedsHttps: 'El micrófono necesita una conexión segura. Abre la Webapp por HTTPS, o mediante un túnel SSH a 127.0.0.1, para usarlo.',
microphonePermissionDenied: 'Se denegó el permiso del micrófono.',
microphoneStartFailed: 'No se pudo iniciar la grabación del micrófono.',
microphoneUnsupported: 'Este runtime no admite grabación de micrófono.',
diff --git a/apps/desktop/src/i18n/fr.ts b/apps/desktop/src/i18n/fr.ts
index 51246ae22e..32515b8014 100644
--- a/apps/desktop/src/i18n/fr.ts
+++ b/apps/desktop/src/i18n/fr.ts
@@ -464,6 +464,7 @@ export const frOverrides = {
microphoneConstraintsUnsupported: 'Les contraintes du microphone ne sont pas prises en charge par cet appareil.',
microphoneFailed: 'Échec du microphone',
microphoneInUse: 'Le microphone est déjà utilisé par une autre application.',
+ microphoneNeedsHttps: "Le micro nécessite une connexion sécurisée. Ouvrez la Webapp en HTTPS, ou via un tunnel SSH vers 127.0.0.1, pour l'utiliser.",
microphonePermissionDenied: "L'autorisation du microphone a été refusée.",
microphoneStartFailed: "Impossible de démarrer l'enregistrement du microphone.",
microphoneUnsupported: "Ce runtime ne prend pas en charge l'enregistrement micro.",
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 6c02751744..05db72758b 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -159,6 +159,7 @@ export const jaOverrides = {
microphoneConstraintsUnsupported: 'このデバイスはマイクの制約をサポートしていません。',
microphoneFailed: 'マイクが失敗しました',
microphoneInUse: 'マイクは他のアプリで使用中です。',
+ microphoneNeedsHttps: 'マイクには安全な接続が必要です。使うには、Webapp を HTTPS か 127.0.0.1 への SSH トンネル経由で開いてください。',
microphonePermissionDenied: 'マイクのアクセス許可が拒否されました。',
microphoneStartFailed: 'マイクの録音を開始できませんでした。',
microphoneUnsupported: 'このランタイムはマイク録音をサポートしていません。',
diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts
index e67bd8960d..8e91e82329 100644
--- a/apps/desktop/src/i18n/ru.ts
+++ b/apps/desktop/src/i18n/ru.ts
@@ -147,6 +147,7 @@ export const ruOverrides = {
microphoneConstraintsUnsupported: 'Ограничения микрофона не поддерживаются этим устройством.',
microphoneFailed: 'Ошибка микрофона',
microphoneInUse: 'Микрофон уже используется другим приложением.',
+ microphoneNeedsHttps: 'Микрофону нужно защищённое соединение. Откройте Webapp по HTTPS или через SSH-туннель к 127.0.0.1, чтобы им пользоваться.',
microphonePermissionDenied: 'Доступ к микрофону запрещён.',
microphoneStartFailed: 'Не удалось начать запись с микрофона.',
microphoneUnsupported: 'Это окружение не поддерживает запись с микрофона.',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 59c05bc3cf..4ce6eb6f18 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -481,6 +481,7 @@ catalog: CatalogTranslations
microphoneConstraintsUnsupported: string
microphoneFailed: string
microphoneInUse: string
+ microphoneNeedsHttps: string
microphonePermissionDenied: string
microphoneStartFailed: string
microphoneUnsupported: string
diff --git a/apps/desktop/src/i18n/zh-hant_diagnostics.ts b/apps/desktop/src/i18n/zh-hant_diagnostics.ts
index ac186d836e..62da611e24 100644
--- a/apps/desktop/src/i18n/zh-hant_diagnostics.ts
+++ b/apps/desktop/src/i18n/zh-hant_diagnostics.ts
@@ -53,6 +53,7 @@ export const zhHantDiagnostics = {
microphoneConstraintsUnsupported: '此裝置不支援目前的麥克風限制條件。',
microphoneFailed: '麥克風發生錯誤',
microphoneInUse: '麥克風正被其他應用程式使用中。',
+ microphoneNeedsHttps: '麥克風需要安全連線。請透過 HTTPS 或到 127.0.0.1 的 SSH 通道開啟 Webapp 後使用。',
microphonePermissionDenied: '麥克風權限被拒絕。',
microphoneStartFailed: '無法開始麥克風錄音。',
microphoneUnsupported: '目前執行環境不支援麥克風錄音。',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 3dd7876eb6..d41e5010b7 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -244,6 +244,7 @@ export const zhOverrides = {
microphoneConstraintsUnsupported: '此设备不支持当前麦克风约束。',
microphoneFailed: '麦克风出错',
microphoneInUse: '麦克风正被其他应用占用。',
+ microphoneNeedsHttps: '麦克风需要安全连接。请通过 HTTPS 或到 127.0.0.1 的 SSH 隧道打开 Webapp 后使用。',
microphonePermissionDenied: '麦克风权限被拒绝。',
microphoneStartFailed: '无法开始麦克风录音。',
microphoneUnsupported: '当前运行环境不支持麦克风录音。',
diff --git a/apps/desktop/src/lib/voice-microphone-unsupported.test.ts b/apps/desktop/src/lib/voice-microphone-unsupported.test.ts
index d24368a135..264894eaac 100644
--- a/apps/desktop/src/lib/voice-microphone-unsupported.test.ts
+++ b/apps/desktop/src/lib/voice-microphone-unsupported.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { micError } from '@/app/chat/composer/hooks/use-mic-recorder'
import { en } from '@/i18n/en'
@@ -26,6 +26,26 @@ it('live voice fails with "microphone unsupported" without mediaDevices, before
expect(peers).not.toHaveBeenCalled()
})
+describe('on an insecure origin', () => {
+ beforeEach(() => Object.defineProperty(window, 'isSecureContext', { configurable: true, value: false }))
+ afterEach(() => {
+ Reflect.deleteProperty(window, 'isSecureContext')
+ delete document.documentElement.dataset.hermesDesktopHost
+ })
+
+ const unsupported = () => micError(new DOMException('no mic here', 'NotSupportedError'), en.notifications.voice)
+
+ it('the Webapp says HTTPS or a tunnel brings the microphone back', () => {
+ document.documentElement.dataset.hermesDesktopHost = 'browser'
+
+ expect(unsupported().message).toBe(en.notifications.voice.microphoneNeedsHttps)
+ })
+
+ it('native Desktop keeps the plain "microphone unsupported" copy', () => {
+ expect(unsupported().message).toBe(en.notifications.voice.microphoneUnsupported)
+ })
+})
+
it('barge-in never reaches for a microphone it cannot have', () => {
const audioContext = vi.fn()
vi.stubGlobal('AudioContext', audioContext)
diff --git a/locales/_keys.desktop.json b/locales/_keys.desktop.json
index c594532ff9..66fa0a0ef6 100644
--- a/locales/_keys.desktop.json
+++ b/locales/_keys.desktop.json
@@ -2222,6 +2222,7 @@
"notifications.voice.microphoneConstraintsUnsupported",
"notifications.voice.microphoneFailed",
"notifications.voice.microphoneInUse",
+ "notifications.voice.microphoneNeedsHttps",
"notifications.voice.microphonePermissionDenied",
"notifications.voice.microphoneStartFailed",
"notifications.voice.microphoneUnsupported",
From 49c34e10967c67da5c8ed110768e5aa1a9632a10 Mon Sep 17 00:00:00 2001
From: unsupportedpastels
Date: Mon, 26 Oct 2026 03:00:05 +0000
Subject: [PATCH 113/114] fix(webapp): ignore a kept LAN sign-in registration
in --status on a private Webapp
Web Access keeps its LAN sign-in registration after webapp.host goes back to a
loopback bind. webapp-launch reads that registration only behind the sign-in
gate; a private Webapp opens its loopback launch link. --status still picked the
registration, so it warned that plain HTTP loses features while launch opened a
secure loopback address.
_browser_address now falls back to the registration only when the listener is
sign-in gated, using the condition gateway.run_api.start_gateway_api applies.
---
hermes_cli/webapp_launcher.py | 14 ++++++++++----
tests/hermes_cli/test_webapp_launcher.py | 12 ++++++++++++
2 files changed, 22 insertions(+), 4 deletions(-)
diff --git a/hermes_cli/webapp_launcher.py b/hermes_cli/webapp_launcher.py
index f542801901..08ee365b1d 100644
--- a/hermes_cli/webapp_launcher.py
+++ b/hermes_cli/webapp_launcher.py
@@ -206,11 +206,17 @@ def _plain_http_note(url: str) -> str | None:
def _browser_address(target: Path, settings, served_url: str) -> str:
"""Where a browser opens the Webapp the gateway serves at *served_url* (its loopback dial origin), as
- ``webapp-launch`` works it out behind sign-in: ``webapp.public_url`` when set, else the LAN address
- Web Access registered Nous sign-in for, else *served_url*. Read from *target*'s config now, not from
- when the gateway started."""
+ ``webapp-launch`` works it out: ``webapp.public_url`` when set, else behind sign-in the LAN address
+ Web Access registered Nous sign-in for, else *served_url* (a private Webapp's launch link). Read from
+ *target*'s config now, not from when the gateway started."""
+ from hermes_cli.web_server_boundary import public_hosts_engage_gate, public_url_hosts, should_require_auth
from hermes_cli.webapp_access import registered_lan_origin
- return settings.public_url or registered_lan_origin(target, urlsplit(served_url).port) or served_url
+ # The sign-in gate gateway.run_api.start_gateway_api engages on the Webapp's listener; without it
+ # webapp-launch hands out the loopback launch link and never reads the registration, which outlives
+ # a move back to a loopback bind.
+ gated = should_require_auth(settings.host) or public_hosts_engage_gate(public_url_hosts(settings.public_url))
+ lan = registered_lan_origin(target, urlsplit(served_url).port) if gated else None
+ return settings.public_url or lan or served_url
def _status(target: Path) -> int:
diff --git a/tests/hermes_cli/test_webapp_launcher.py b/tests/hermes_cli/test_webapp_launcher.py
index 5118ad1964..9583d6d62a 100644
--- a/tests/hermes_cli/test_webapp_launcher.py
+++ b/tests/hermes_cli/test_webapp_launcher.py
@@ -481,6 +481,18 @@ def test_status_gets_no_plain_http_warning_when_browsers_open_a_secure_address(w
assert "plain http" not in capsys.readouterr().out.lower()
+@pytest.mark.parametrize("host", ["127.0.0.1", "localhost"])
+def test_status_of_a_private_webapp_ignores_a_lan_sign_in_registration_it_kept(world, capsys, host):
+ """Web Access keeps its LAN sign-in registration after webapp.host goes back to loopback. With no
+ sign-in gate, hermes webapp opens the loopback launch link, so --status does not warn."""
+ _home(world["root"], f"webapp:\n enabled: true\n host: {host}\n")
+ _register_lan_sign_in(world, "192.168.1.20")
+ assert launcher.cmd_webapp(_args(status=True)) == 0
+ out = capsys.readouterr().out
+ assert "Serving at http://127.0.0.1:9120" in out
+ assert "plain http" not in out.lower()
+
+
@pytest.mark.parametrize("url", ["http://127.0.0.1:9120/", "http://127.0.0.2:9120/", "http://localhost:9120/",
"http://hermes.localhost:9120/", "http://[::1]:9120/",
"https://hermes.example.ts.net/"])
From 5239b1323e86fb3c185ebe0a1921664cb0cd591f Mon Sep 17 00:00:00 2001
From: Calvin Ng
Date: Mon, 26 Oct 2026 14:50:53 +1100
Subject: [PATCH 114/114] fix(models): make GLM-5.3 the silent default model
(#144810)
GLM-5.2 became the silent default (the model Hermes lands on when the user
never picked one) in #64635 as a capable low-cost pick. GLM-5.3 has since
superseded it at about the same price on Nous Portal and OpenRouter, with
the same 1M context, and both curated catalogs already list it.
Move every copy of the default together:
- PREFERRED_SILENT_DEFAULT_MODEL and the OpenRouter "default" description.
- The "default": true label in website/static/api/model-catalog.json,
regenerated with scripts/build_model_catalog.py, so clients rotate from
their cached catalog without a release.
- The TUI's _resolve_model last resort, which hardcoded "z-ai/glm-5.2" and
would drift on every rotation; it now reads the constant from the
data-only models_catalog_static module.
- The example manifest in the model catalog reference docs.
The new TUI test pins the no-model fallback to the constant, and the
manifest contract test is renamed since it checks whatever the constant is.
---
hermes_cli/models_catalog_static.py | 4 +-
tests/hermes_cli/test_model_catalog.py | 2 +-
.../test_resolve_model_silent_default.py | 39 +++++++++++++++++++
tui_gateway/agent_factory.py | 4 +-
website/docs/reference/model-catalog.md | 4 +-
website/static/api/model-catalog.json | 14 +++----
6 files changed, 54 insertions(+), 13 deletions(-)
create mode 100644 tests/tui_gateway/test_resolve_model_silent_default.py
diff --git a/hermes_cli/models_catalog_static.py b/hermes_cli/models_catalog_static.py
index 821e04ce84..ec14790391 100644
--- a/hermes_cli/models_catalog_static.py
+++ b/hermes_cli/models_catalog_static.py
@@ -23,7 +23,7 @@ _OPENROUTER_DESCRIPTIONS = {
"deepseek/deepseek-v4.1-flash-1017": "dated snapshot of v4.1-flash",
"deepseek/deepseek-v4-flash-0731": "dated snapshot of v4-flash",
"moonshotai/kimi-k3": "recommended",
- "z-ai/glm-5.2": "default",
+ "z-ai/glm-5.3": "default",
"z-ai/glm-5.3-flashx": "high-speed tier of glm-5.3-flash",
"openrouter/pareto-code": "auto-routes to cheapest coder meeting openrouter.min_coding_score",
"openai/gpt-6-astra-fast": "2x price, priority tier",
@@ -544,7 +544,7 @@ _PROVIDER_ALIASES = dict((
# MUST match the labeled entry in website/static/api/model-catalog.json. Deliberately a capable
# low-cost model rather than the curated lists' entry [0]: aggregator lists are ordered
# most-capable-first, so [0] is the priciest Anthropic flagship.
-PREFERRED_SILENT_DEFAULT_MODEL = "z-ai/glm-5.2"
+PREFERRED_SILENT_DEFAULT_MODEL = "z-ai/glm-5.3"
# Providers whose *silent* auto-default goes through the cost-safe catalog-labeled default
diff --git a/tests/hermes_cli/test_model_catalog.py b/tests/hermes_cli/test_model_catalog.py
index 81cb75f595..0f36d5d32e 100644
--- a/tests/hermes_cli/test_model_catalog.py
+++ b/tests/hermes_cli/test_model_catalog.py
@@ -361,7 +361,7 @@ class TestDefaultModelFromCache:
fetch.assert_not_called()
- def test_shipped_manifest_labels_glm52_default(self, isolated_home):
+ def test_shipped_manifest_labels_the_silent_default(self, isolated_home):
"""Contract with the in-repo manifest: both provider blocks label the
same default entry the code constant points at."""
from hermes_cli import model_catalog
diff --git a/tests/tui_gateway/test_resolve_model_silent_default.py b/tests/tui_gateway/test_resolve_model_silent_default.py
new file mode 100644
index 0000000000..af5e5ce449
--- /dev/null
+++ b/tests/tui_gateway/test_resolve_model_silent_default.py
@@ -0,0 +1,39 @@
+"""Tests: the TUI's no-model fallback follows the one silent-default constant.
+
+``_resolve_model`` with no env seed and no configured model lands on the
+cached catalog's labeled default, and on ``PREFERRED_SILENT_DEFAULT_MODEL``
+when that lookup fails. A hardcoded copy of the id there drifts every time
+the default rotates.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+import hermes_cli.models as models_mod
+from hermes_cli.models_catalog_static import PREFERRED_SILENT_DEFAULT_MODEL
+from tui_gateway import server
+
+
+@pytest.fixture
+def no_model_configured(monkeypatch):
+ monkeypatch.delenv("HERMES_MODEL", raising=False)
+ monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
+ monkeypatch.setattr(server, "_load_cfg", dict)
+
+
+def test_unset_model_uses_catalog_labeled_default(monkeypatch, no_model_configured):
+ monkeypatch.setattr(
+ models_mod, "get_preferred_silent_default_model", lambda provider="openrouter": "example/labeled"
+ )
+
+ assert server._resolve_model() == "example/labeled"
+
+
+def test_unset_model_falls_back_to_the_constant_when_lookup_fails(monkeypatch, no_model_configured):
+ def _boom(provider="openrouter"):
+ raise RuntimeError("catalog lookup unavailable")
+
+ monkeypatch.setattr(models_mod, "get_preferred_silent_default_model", _boom)
+
+ assert server._resolve_model() == PREFERRED_SILENT_DEFAULT_MODEL
diff --git a/tui_gateway/agent_factory.py b/tui_gateway/agent_factory.py
index 73647e40f6..a0fa0eae55 100644
--- a/tui_gateway/agent_factory.py
+++ b/tui_gateway/agent_factory.py
@@ -21,7 +21,9 @@ def _resolve_model() -> str:
with contextlib.suppress(Exception):
from hermes_cli.models import get_preferred_silent_default_model
return get_preferred_silent_default_model()
- return "z-ai/glm-5.2"
+ # Data-only module (no heavy imports), so this holds when the lookup above fails.
+ from hermes_cli.models_catalog_static import PREFERRED_SILENT_DEFAULT_MODEL
+ return PREFERRED_SILENT_DEFAULT_MODEL
def _resolve_session_platform() -> str:
diff --git a/website/docs/reference/model-catalog.md b/website/docs/reference/model-catalog.md
index e0b98a3ed7..19ee831a2d 100644
--- a/website/docs/reference/model-catalog.md
+++ b/website/docs/reference/model-catalog.md
@@ -29,7 +29,7 @@ Published on every merge to `main` via the existing `deploy-site.yml` GitHub Pag
"openrouter": {
"metadata": {},
"models": [
- {"id": "z-ai/glm-5.2", "description": "default", "default": true},
+ {"id": "z-ai/glm-5.3", "description": "default", "default": true},
{"id": "moonshotai/kimi-k3", "description": "recommended", "metadata": {}},
{"id": "openai/gpt-5.4", "description": ""}
]
@@ -37,7 +37,7 @@ Published on every merge to `main` via the existing `deploy-site.yml` GitHub Pag
"nous": {
"metadata": {},
"models": [
- {"id": "z-ai/glm-5.2", "default": true},
+ {"id": "z-ai/glm-5.3", "default": true},
{"id": "anthropic/claude-opus-4.7"},
{"id": "moonshotai/kimi-k3"}
]
diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json
index 0dc501cccd..bf5d6a2ec1 100644
--- a/website/static/api/model-catalog.json
+++ b/website/static/api/model-catalog.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "updated_at": "2026-10-17T11:22:42Z",
+ "updated_at": "2026-10-26T02:45:22Z",
"metadata": {
"source": "hermes-agent repo",
"docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog"
@@ -178,7 +178,8 @@
},
{
"id": "z-ai/glm-5.3",
- "description": ""
+ "description": "default",
+ "default": true
},
{
"id": "z-ai/glm-5.3-flash",
@@ -190,8 +191,7 @@
},
{
"id": "z-ai/glm-5.2",
- "description": "default",
- "default": true
+ "description": ""
},
{
"id": "xiaomi/mimo-v2.6-pro",
@@ -431,7 +431,8 @@
"id": "minimax/minimax-m3"
},
{
- "id": "z-ai/glm-5.3"
+ "id": "z-ai/glm-5.3",
+ "default": true
},
{
"id": "z-ai/glm-5.3-flash"
@@ -440,8 +441,7 @@
"id": "z-ai/glm-5.3-flashx"
},
{
- "id": "z-ai/glm-5.2",
- "default": true
+ "id": "z-ai/glm-5.2"
},
{
"id": "xiaomi/mimo-v2.6-pro"