From d573e7c9e1639d7c98c02f3face6f599464f8758 Mon Sep 17 00:00:00 2001 From: emozilla Date: Thu, 18 Jun 2026 16:00:26 -0400 Subject: [PATCH 01/28] fix(dashboard): use DS Button prefix/size API instead of inline icons @nous-research/ui@0.18.2 Button is grid-based: size=xs is an aspect-square icon-only box, and icons belong in prefix/suffix. The dashboard used shadcn-style size=xs + inline text children, which forced text buttons into broken tall squares (Configure, Run setup, Select, Save keys) and split icon/label across grid columns elsewhere (Schedule it, Prune/Delete actions). Move leading icons to prefix and size text buttons as sm/default. For the post-setup spinner, drive the spin from a button-level [&_svg]:animate-spin selector since the prefix slot clones the icon and overwrites its className. - ToolsetConfigDrawer: Select, Save keys, Run setup - SkillsPage: New skill, Configure - AutomationBlueprints: Schedule it - SessionsPage: Prune old sessions, Delete empty, Delete selected --- web/src/components/AutomationBlueprints.tsx | 7 +++-- web/src/components/ToolsetConfigDrawer.tsx | 32 ++++++++++++--------- web/src/pages/SessionsPage.tsx | 7 ++--- web/src/pages/SkillsPage.tsx | 7 ++--- 4 files changed, 30 insertions(+), 23 deletions(-) diff --git a/web/src/components/AutomationBlueprints.tsx b/web/src/components/AutomationBlueprints.tsx index 10d1270fa059..209c75e0682a 100644 --- a/web/src/components/AutomationBlueprints.tsx +++ b/web/src/components/AutomationBlueprints.tsx @@ -149,8 +149,11 @@ function BlueprintCard({

) : null}
-
diff --git a/web/src/components/ToolsetConfigDrawer.tsx b/web/src/components/ToolsetConfigDrawer.tsx index 792393c9285b..a042a780ad5f 100644 --- a/web/src/components/ToolsetConfigDrawer.tsx +++ b/web/src/components/ToolsetConfigDrawer.tsx @@ -309,7 +309,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr ) : ( )} diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index c48d24538766..2d70c399af2b 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -794,10 +794,9 @@ export default function SessionsPage() { , ); @@ -1491,8 +1490,8 @@ export default function SessionsPage() { onClick={() => setDeleteEmptyOpen(true)} aria-label={t.sessions.deleteEmpty} title={t.sessions.deleteEmpty} + prefix={} > - {t.sessions.deleteEmpty} ({emptyCount}) @@ -1565,8 +1564,8 @@ export default function SessionsPage() { "{count}", String(selectedIds.size), )} + prefix={} > - {t.sessions.deleteSelected.replace( "{count}", diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index e8f764d8e862..cb6beef22fae 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -493,9 +493,8 @@ export default function SkillsPage() { .replace("{s}", activeSkills.length !== 1 ? "s" : "")} From 81ff916e575f8ccae4d1aacb0c6dc7bf537820a8 Mon Sep 17 00:00:00 2001 From: kyssta-exe Date: Tue, 16 Jun 2026 12:24:00 +0000 Subject: [PATCH 02/28] fix(agent): flush un-persisted messages before session rotation (#47202) compress_context() rotates the session (end_session -> create_session) mid-turn when auto-compress triggers, but never called _flush_messages_to_session_db() first. Messages generated during the current turn that hadn't been persisted to state.db were silently lost. The same bug existed in cli.py:new_session() (/new command). Both paths now flush un-persisted messages before ending the old session. --- agent/conversation_compression.py | 10 ++++++++++ cli.py | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 318e67d0faf2..5c7d299f0a40 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -512,6 +512,16 @@ def _release_lock() -> None: old_title = agent._session_db.get_session_title(agent.session_id) # Trigger memory extraction on the old session before it rotates. agent.commit_memory_session(messages) + # Flush any un-persisted messages from the current turn to the + # old session *before* rotating. compress_context() can be + # called mid-turn (auto-compress when context exceeds threshold) + # at a point when _flush_messages_to_session_db() has not yet + # run. Without this, messages generated during the current turn + # are silently lost on session rotation (#47202). + try: + agent._flush_messages_to_session_db(messages) + except Exception: + pass # best-effort — don't block compression on a flush error agent._session_db.end_session(agent.session_id, "compression") old_session_id = agent.session_id agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" diff --git a/cli.py b/cli.py index 07fa2b72513d..ff5db7d01e0a 100644 --- a/cli.py +++ b/cli.py @@ -5975,6 +5975,18 @@ def new_session(self, silent=False, title=None): old_session_id = self.session_id if self._session_db and old_session_id: + # Flush any un-persisted messages from the current turn to the + # old session *before* rotating. /new can be called mid-turn + # when _flush_messages_to_session_db() has not yet run — without + # this, messages generated during the current turn are silently + # lost on session rotation (#47202). + if self.agent: + try: + self.agent._flush_messages_to_session_db( + self.conversation_history + ) + except Exception: + pass # best-effort try: self._session_db.end_session(old_session_id, "new_session") except Exception: From 0879d5cc8f3a257e2607936ac1e4aebe7be37220 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:35:43 -0700 Subject: [PATCH 03/28] fix(gateway): preserve original transcript when /compress rotation is skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual /compress handler called rewrite_transcript() unconditionally on the session id returned by _compress_context(). When rotation does not occur (e.g. _session_db unavailable, or the DB split raised), session_id is unchanged and rewrite_transcript() DELETEs the original messages and replaces them with only the compressed summary — permanent data loss (#44794, #39704). Guard the rewrite on actual rotation: only overwrite when _compress_context produced a new session id. Otherwise leave the original transcript intact and log a warning. --- gateway/slash_commands.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index bfcef143f449..04c3f4ca89f2 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2588,14 +2588,29 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: # session_id for the continuation. Write the compressed messages # into the NEW session so the original history stays searchable. new_session_id = tmp_agent.session_id - if new_session_id != session_entry.session_id: + rotated = new_session_id != session_entry.session_id + if rotated: session_entry.session_id = new_session_id self.session_store._save() self._sync_telegram_topic_binding( source, session_entry, reason="compress-command", ) - self.session_store.rewrite_transcript(new_session_id, compressed) + # Only rewrite the transcript when rotation actually produced a + # NEW session id. If _compress_context could not rotate (e.g. + # _session_db unavailable, or the DB split raised), session_id + # is unchanged and rewrite_transcript() would DELETE the + # original messages and replace them with only the compressed + # summary — permanent data loss (#44794, #39704). In that case + # leave the original transcript intact. + if rotated: + self.session_store.rewrite_transcript(new_session_id, compressed) + else: + logger.warning( + "Manual /compress: session rotation did not occur " + "(session_id unchanged) — preserving original transcript " + "instead of overwriting it (#44794)." + ) # Reset stored token count — transcript changed, old value is stale self.session_store.update_session( session_entry.session_key, last_prompt_tokens=0 From 4ed2f3399418f2a2fd1d060878bb4b2f17565a87 Mon Sep 17 00:00:00 2001 From: alelpoan <155192176+alelpoan@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:44:27 +0300 Subject: [PATCH 04/28] fix(thread): allow scrolling long user messages in chat history (#48619) --- apps/desktop/src/components/assistant-ui/thread.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 1c8f41d66e63..c5b20cedd3e0 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -827,7 +827,7 @@ function StickyHumanMessageContainer({ attachments, children }: { attachments?: // so without the carve-out, clicking a stuck bubble drags the window instead of // opening the edit composer. const USER_BUBBLE_BASE_CLASS = - 'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]' + 'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-y-auto rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]' const USER_ACTION_ICON_BUTTON_CLASS = 'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70' From 9705e7944ae46401ab9cb011ebd9fbcd5667b981 Mon Sep 17 00:00:00 2001 From: islam666 Date: Thu, 18 Jun 2026 07:35:08 +0000 Subject: [PATCH 05/28] fix(picker): remove max_models=50 cap in interactive model pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive model pickers (Desktop REST API, TUI model.options, CLI /model) were hard-capped at max_models=50, which truncated large provider catalogs like Kilo Gateway (336 models) to just 50 entries. This made most models undiscoverable via the picker search box. Changes: - Change build_models_payload() default from max_models=50 to None (unlimited) - Change list_authenticated_providers() default from max_models=8 to None - Change list_picker_providers() default from max_models=8 to None - Fix all [:max_models] slicing to handle None as 'no limit' - Remove max_models=50 from 5 interactive picker callers: * web_server.py: get_model_options (Desktop /api/model/options) * web_server.py: get_recommended_default_model * model_switch.py: prewarm_picker_cache_async * tui_gateway/server.py: model.options JSON-RPC * cli.py: HermesCLI model picker - Telegram/Discord inline keyboard picker (gateway/slash_commands.py) still passes max_models=50 explicitly — unchanged behavior. The total_models field was already in the response payload and is now meaningful since models.length == total_models for interactive pickers. Fixes #48279 --- cli.py | 2 +- hermes_cli/inventory.py | 2 +- hermes_cli/model_switch.py | 13 ++++++------- hermes_cli/web_server.py | 3 +-- tests/hermes_cli/test_inventory.py | 28 ++++++++++++++++++++++++++++ tui_gateway/server.py | 1 - 6 files changed, 37 insertions(+), 12 deletions(-) diff --git a/cli.py b/cli.py index ff5db7d01e0a..b1c9a4bc8ef0 100644 --- a/cli.py +++ b/cli.py @@ -6971,7 +6971,7 @@ def _handle_model_switch(self, cmd_original: str): try: if ctx is None: raise RuntimeError("inventory context unavailable") - providers = build_models_payload(ctx, max_models=50)["providers"] + providers = build_models_payload(ctx)["providers"] except Exception: providers = [] diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 2c7d9c5bf5ce..7584dd887e03 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -117,7 +117,7 @@ def build_models_payload( pricing: bool = False, capabilities: bool = False, force_fresh_nous_tier: bool = False, - max_models: int = 50, + max_models: int | None = None, ) -> dict: """Build the ``{providers, model, provider}`` shape every consumer needs from a single substrate call. diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index a27292747bef..4da54beedf6d 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1188,7 +1188,6 @@ def _warm() -> None: current_model=ctx.current_model, user_providers=ctx.user_providers, custom_providers=ctx.custom_providers, - max_models=50, ) except Exception: # Best-effort warmup — never surface errors into the session. @@ -1206,7 +1205,7 @@ def list_authenticated_providers( custom_providers: list | None = None, *, force_fresh_nous_tier: bool = False, - max_models: int = 8, + max_models: int | None = None, current_model: str = "", ) -> List[dict]: """Detect which providers have credentials and list their curated models. @@ -1426,7 +1425,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: if hermes_id in _MODELS_DEV_PREFERRED: model_ids = _merge_with_models_dev(hermes_id, model_ids) total = len(model_ids) - top = model_ids[:max_models] + top = model_ids[:max_models] if max_models else model_ids slug = hermes_id pinfo = _mdev_pinfo(mdev_id) @@ -1589,7 +1588,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: if hermes_slug in _MODELS_DEV_PREFERRED: model_ids = _merge_with_models_dev(hermes_slug, model_ids) total = len(model_ids) - top = model_ids[:max_models] + top = model_ids[:max_models] if max_models else model_ids results.append({ "slug": hermes_slug, @@ -1664,7 +1663,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: if not _cp_model_ids: _cp_model_ids = curated.get(_cp.slug, []) _cp_total = len(_cp_model_ids) - _cp_top = _cp_model_ids[:max_models] + _cp_top = _cp_model_ids[:max_models] if max_models else _cp_model_ids results.append({ "slug": _cp.slug, @@ -2040,7 +2039,7 @@ def list_picker_providers( current_base_url: str = "", user_providers: dict = None, custom_providers: list | None = None, - max_models: int = 8, + max_models: int | None = None, current_model: str = "", ) -> List[dict]: """Interactive-picker variant of :func:`list_authenticated_providers`. @@ -2083,7 +2082,7 @@ def list_picker_providers( except Exception: live_ids = list(p.get("models", [])) p = dict(p) - p["models"] = live_ids[:max_models] + p["models"] = live_ids[:max_models] if max_models else live_ids p["total_models"] = len(live_ids) has_models = bool(p.get("models")) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index fcda37d6dfed..c8fe020fe151 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3323,7 +3323,6 @@ def get_model_options(profile: Optional[str] = None): with _profile_scope(profile): return build_models_payload( load_picker_context(), - max_models=50, include_unconfigured=True, picker_hints=True, canonical_order=True, @@ -3398,7 +3397,7 @@ def get_recommended_default_model(provider: str = ""): try: from hermes_cli.inventory import build_models_payload, load_picker_context - payload = build_models_payload(load_picker_context(), max_models=50) + payload = build_models_payload(load_picker_context()) for row in payload.get("providers", []): if str(row.get("slug", "")).lower() == slug: models = row.get("models") or [] diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index 6eeb7a535be1..c7d761515b1a 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -660,3 +660,31 @@ def test_two_custom_providers_with_overlap_both_survive(): assert a_row["total_models"] == 2 assert b_row["total_models"] == 2 + +def test_build_models_payload_no_max_models_returns_full_list(): + """When max_models is not passed (None), build_models_payload must + return the full model list — not truncate to the old default of 50. + Regression for #48279: Kilo Gateway picker was capped at 50 of 336 + models, making most models undiscoverable via search.""" + full_models = [f"model-{i}" for i in range(100)] + rows = [ + { + "slug": "kilocode", + "name": "Kilo Code", + "models": full_models, + "total_models": len(full_models), + "is_current": False, + "is_user_defined": False, + "source": "built-in", + }, + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + # No max_models argument — should return all 100 models + payload = build_models_payload(ctx) + + kilo_row = next(r for r in payload["providers"] if r["slug"] == "kilocode") + assert kilo_row["models"] == full_models + assert kilo_row["total_models"] == 100 + assert len(kilo_row["models"]) == 100 + diff --git a/tui_gateway/server.py b/tui_gateway/server.py index d2436b86d082..f13a4c5c7609 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -9496,7 +9496,6 @@ def _(rid, params: dict) -> dict: canonical_order=True, pricing=True, capabilities=True, - max_models=50, ) return _ok(rid, payload) except Exception as e: From 30420455403cee431f2fb5a9e665649aef0efccd Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:41:32 -0700 Subject: [PATCH 06/28] fix(picker): keep max_models=0 distinct from unlimited; lock cap semantics Follow-up to the cap-removal salvage. The contributor guarded the new unlimited default with `[:max_models] if max_models else ...`, which conflates max_models=0 (used by slug-only callers that want an empty model list) with None (unlimited). Tighten to `is not None` at all five slicing sites in list_authenticated_providers / list_picker_providers, and add a regression test asserting the three-way contract: None=full, 0=empty, N=first N. --- hermes_cli/model_switch.py | 10 ++-- tests/hermes_cli/test_model_catalog.py | 65 ++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 4da54beedf6d..eae987fbbdfe 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1425,7 +1425,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: if hermes_id in _MODELS_DEV_PREFERRED: model_ids = _merge_with_models_dev(hermes_id, model_ids) total = len(model_ids) - top = model_ids[:max_models] if max_models else model_ids + top = model_ids[:max_models] if max_models is not None else model_ids slug = hermes_id pinfo = _mdev_pinfo(mdev_id) @@ -1588,7 +1588,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: if hermes_slug in _MODELS_DEV_PREFERRED: model_ids = _merge_with_models_dev(hermes_slug, model_ids) total = len(model_ids) - top = model_ids[:max_models] if max_models else model_ids + top = model_ids[:max_models] if max_models is not None else model_ids results.append({ "slug": hermes_slug, @@ -1663,7 +1663,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: if not _cp_model_ids: _cp_model_ids = curated.get(_cp.slug, []) _cp_total = len(_cp_model_ids) - _cp_top = _cp_model_ids[:max_models] if max_models else _cp_model_ids + _cp_top = _cp_model_ids[:max_models] if max_models is not None else _cp_model_ids results.append({ "slug": _cp.slug, @@ -1812,7 +1812,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: "name": "Custom endpoint", "is_current": True, "is_user_defined": True, - "models": _models[:max_models] if max_models else _models, + "models": _models[:max_models] if max_models is not None else _models, "total_models": len(_models), "source": "model-config", "api_url": str(current_base_url).strip().rstrip("/"), @@ -2082,7 +2082,7 @@ def list_picker_providers( except Exception: live_ids = list(p.get("models", [])) p = dict(p) - p["models"] = live_ids[:max_models] if max_models else live_ids + p["models"] = live_ids[:max_models] if max_models is not None else live_ids p["total_models"] = len(live_ids) has_models = bool(p.get("models")) diff --git a/tests/hermes_cli/test_model_catalog.py b/tests/hermes_cli/test_model_catalog.py index 7a1cbd54c30d..b464fb046a9a 100644 --- a/tests/hermes_cli/test_model_catalog.py +++ b/tests/hermes_cli/test_model_catalog.py @@ -423,6 +423,71 @@ def test_picker_nous_row_uses_curated_list(self, tmp_path, monkeypatch): assert nous_row is not None, "nous row must appear when authed" assert nous_row["models"] == expected + def test_picker_max_models_cap_semantics(self, tmp_path, monkeypatch): + """The cap argument has three distinct meanings on the real slicing + path: ``None`` = unlimited (the cap-removal fix, #48297), ``0`` = no + models (preserved for slug-only callers), an int N = first N. Guards + the ``is not None`` distinction the cap-removal follow-up introduced — + a ``if max_models`` (falsy) check would conflate ``0`` with unlimited. + """ + import importlib + from hermes_cli import model_catalog + from hermes_cli.models import get_curated_nous_model_ids + importlib.reload(model_catalog) + try: + from hermes_cli.model_switch import ( + list_authenticated_providers, + list_picker_providers, + ) + + active_home = Path(os.environ["HERMES_HOME"]) + (active_home / "auth.json").write_text( + json.dumps( + { + "providers": {"nous": {"access_token": "fake"}}, + "credential_pool": {}, + } + ) + ) + with patch.object( + model_catalog, "_fetch_manifest", return_value=_valid_manifest() + ), patch("hermes_cli.models.check_nous_free_tier", return_value=False), patch( + "hermes_cli.models.union_with_portal_free_recommendations", + side_effect=lambda ids, *a, **k: (ids, {}), + ), patch( + "hermes_cli.models.union_with_portal_paid_recommendations", + side_effect=lambda ids, *a, **k: (ids, {}), + ): + expected = get_curated_nous_model_ids() + full = list_picker_providers(current_provider="nous", max_models=None) + one = list_picker_providers(current_provider="nous", max_models=1) + # 0 is exercised on list_authenticated_providers (the slug-only + # path); the picker variant drops empty-model rows entirely, so + # the empty-list contract lives on the auth-providers call. + zero = list_authenticated_providers( + current_provider="nous", max_models=0 + ) + finally: + model_catalog.reset_cache() + + def _nous(rows): + return next((r for r in rows if r["slug"] == "nous"), None) + + # Only meaningful when the curated list actually exceeds 1 entry. + assert len(expected) > 1, "test needs a multi-model curated nous list" + + full_row = _nous(full) + assert full_row is not None and full_row["models"] == expected + + one_row = _nous(one) + assert one_row is not None and one_row["models"] == expected[:1] + + zero_row = _nous(zero) + # 0 means an empty model list — NOT unlimited. total_models still real. + assert zero_row is not None + assert zero_row["models"] == [] + assert zero_row["total_models"] == len(expected) + # ----------------------------------------------------------------------------- # Drift guard — prevent the in-repo curated lists from going out of sync with From 49596b70cb2d0d328d68645905febb074e494e77 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 18 Jun 2026 15:56:39 -0500 Subject: [PATCH 07/28] fix(gateway): resume follows the compression tip so post-compression replies render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-compression ends the live session and forks a continuation child (linked via parent_session_id). A long-lived parent keeps its own flushed message rows, so resolve_resume_session_id()'s empty-head walk never redirected it — resuming the parent id reloaded the pre-compression transcript and dropped every turn generated after compression, including the assistant's response. On the desktop this is the recurring "I sent a message, came back, and the reply isn't there" report on large sessions: the chat's routed id is the pre-rotation id, and both the gateway session.resume RPC and the REST /messages read anchored on it. Fix the resolver at the chokepoint: resolve_resume_session_id() now follows the compression-continuation chain forward via get_compression_tip() before its existing empty-head descendant walk. get_compression_tip() only follows children whose parent ended with end_reason='compression' (created after the parent was ended), so delegation/branch children never hijack a resume. This fixes every resume caller at once (REST /messages, CLI --resume, gateway /resume). session.resume in tui_gateway was the one resume path that never called the resolver — it used the raw target id directly. Route it through resolve_resume_session_id() too (non-lazy only; lazy watch windows must stay on their exact child branch). Resolving up front also re-anchors the live-session fast path so a still-live rotated session is reused by its new key instead of rebuilding a duplicate agent on the stale parent. Tests: - resolve_resume_session_id follows the tip even when the parent retains messages, and is not confused by a delegation child. - session.resume binds the agent to the continuation tip and returns the post-compression reply. --- hermes_state.py | 18 ++++++ .../test_resolve_resume_session_id.py | 40 +++++++++++++ tests/test_tui_gateway_server.py | 59 +++++++++++++++++++ tui_gateway/server.py | 21 +++++++ 4 files changed, 138 insertions(+) diff --git a/hermes_state.py b/hermes_state.py index 9653eae017f7..19c6a269b99e 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -2820,6 +2820,24 @@ def resolve_resume_session_id(self, session_id: str) -> str: if not session_id: return session_id + # Follow the compression-continuation chain forward to the live tip + # FIRST. Auto-compression ends the current session and forks a + # continuation child, but a long-lived parent keeps its own flushed + # message rows — so the empty-head walk below never redirects it, and + # resuming the parent id reloads the pre-compression transcript while + # the turns generated *after* compression (and their responses) sit in + # the continuation. ``get_compression_tip`` is lineage-aware: it only + # follows children whose parent ended with ``end_reason='compression'`` + # (created after the parent was ended), so delegation / branch children + # never hijack the resume. This is the fix for the desktop "I came back + # and the reply isn't there" report on large sessions. + try: + tip = self.get_compression_tip(session_id) + except Exception: + tip = session_id + if tip and tip != session_id: + session_id = tip + with self._lock: # If this session already has messages, nothing to redirect. try: diff --git a/tests/hermes_state/test_resolve_resume_session_id.py b/tests/hermes_state/test_resolve_resume_session_id.py index ec637c6d2052..b4dd8717a2ed 100644 --- a/tests/hermes_state/test_resolve_resume_session_id.py +++ b/tests/hermes_state/test_resolve_resume_session_id.py @@ -83,6 +83,46 @@ def test_walks_from_middle_of_chain(db): assert db.resolve_resume_session_id("c") == "d" +def test_follows_compression_tip_when_parent_retains_messages(db): + # The bug behind the desktop "I came back and the reply isn't there" report + # on large sessions: auto-compression ends the live session and forks a + # continuation child, but a long parent keeps its own flushed message rows. + # The empty-head walk below never redirects a non-empty head, so resuming + # the parent id reloaded the pre-compression transcript and the response + # generated *after* compression (which lives in the continuation) was + # missing. resolve_resume_session_id must follow the compression-tip chain + # forward even when the parent still has messages. + base = int(time.time()) - 10_000 + db.create_session("root", source="cli") + db.append_message("root", role="user", content="pre-compression turn") + db.end_session("root", "compression") + db.create_session("cont", source="cli", parent_session_id="root") + db.append_message("cont", role="assistant", content="post-compression reply") + # Force deterministic ordering so the continuation's started_at is clearly + # at/after the parent's ended_at (the get_compression_tip discriminator). + db._conn.execute("UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = 'root'", (base, base + 50)) + db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont'", (base + 100,)) + db._conn.commit() + + assert db.resolve_resume_session_id("root") == "cont" + + +def test_compression_tip_not_confused_with_delegation_child(db): + # A delegation/branch child is created while the parent is still live (the + # parent is NOT ended with end_reason='compression'), so resuming the + # parent must stay on the parent, not get hijacked into the subagent branch. + base = int(time.time()) - 10_000 + db.create_session("conv", source="cli") + db.append_message("conv", role="user", content="parent turn") + db.create_session("subagent", source="cli", parent_session_id="conv") + db.append_message("subagent", role="assistant", content="delegated work") + db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'conv'", (base,)) + db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'subagent'", (base + 100,)) + db._conn.commit() + + assert db.resolve_resume_session_id("conv") == "conv" + + def test_prefers_most_recent_child_when_fork_exists(db): # If a session was somehow forked (two children), pick the latest one. # In practice, compression only produces single-chain shape, but the helper diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index e04d07756a3d..6159dab0c166 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -954,6 +954,65 @@ def get_messages_as_conversation(self, target, include_ancestors=False): assert captured["history_calls"] == [("tip", False), ("tip", True)] +def test_session_resume_follows_compression_tip(monkeypatch, tmp_path): + """Resuming a rotated-out parent id must load the continuation's messages. + + Regression for the desktop "I came back and the reply isn't there" report: + auto-compression ends the live session and forks a continuation child, so a + resume on the parent id (the desktop's routed id when the chat was opened + before it rotated) used to reload the pre-compression transcript and drop + the response generated after compression. session.resume must follow the + compression tip via resolve_resume_session_id. + """ + from hermes_state import SessionDB + + db = SessionDB(db_path=tmp_path / "state.db") + base = int(time.time()) - 10_000 + db.create_session("parent_root", source="tui") + db.append_message("parent_root", role="user", content="pre-compression turn") + db.end_session("parent_root", "compression") + db.create_session("cont_tip", source="tui", parent_session_id="parent_root") + db.append_message("cont_tip", role="assistant", content="post-compression reply") + db._conn.execute( + "UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = 'parent_root'", + (base, base + 50), + ) + db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont_tip'", (base + 100,)) + db._conn.commit() + + captured = {} + + def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs): + captured["agent_session_id"] = session_id + return types.SimpleNamespace(model="test", provider="test") + + monkeypatch.setattr(server, "_get_db", lambda: db) + monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None) + monkeypatch.setattr(server, "_set_session_context", lambda target: []) + monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None) + monkeypatch.setattr(server, "_make_agent", fake_make_agent) + monkeypatch.setattr( + server, "_session_info", lambda agent, *a: {"model": "test", "tools": {}, "skills": {}} + ) + monkeypatch.setattr( + server, "_init_session", lambda sid, key, agent, history, cols=80, **_kwargs: None + ) + + try: + resp = server.handle_request( + {"id": "1", "method": "session.resume", "params": {"session_id": "parent_root"}} + ) + finally: + db.close() + + # The agent must bind to the continuation tip, and the returned transcript + # must include the post-compression reply (which lives only in the tip). + assert resp["result"]["session_key"] == "cont_tip" + assert captured["agent_session_id"] == "cont_tip" + texts = [m.get("text") for m in resp["result"]["messages"]] + assert "post-compression reply" in texts + + def test_session_resume_passes_stored_runtime_to_agent(monkeypatch): captured = {} diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f13a4c5c7609..294e543c230f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4419,6 +4419,27 @@ def _(rid, params: dict) -> dict: found = {} else: return _err(rid, 4007, "session not found") + + # Follow the compression-continuation chain to the live tip so a resume on + # a rotated-out parent id binds to the descendant that actually holds the + # post-compression turns. Auto-compression ends the session and forks a + # continuation child; without this, resuming the original id (the desktop's + # routed id when the chat was opened before it rotated) reloads the parent + # transcript and the response generated after compression is missing — the + # "I came back and the reply isn't there" bug on large sessions. Resolving + # here also re-anchors the fast path below so a still-live rotated session + # is reused (by its new key) instead of rebuilding a duplicate agent on the + # stale parent. Skipped for lazy watch windows, which intentionally attach + # to the exact child branch they were opened on. + if found and not is_truthy_value(params.get("lazy", False)): + try: + tip = db.resolve_resume_session_id(target) + except Exception: + tip = target + if tip and tip != target: + target = tip + found = db.get_session(target) or found + profile_resume_cwd = str(found.get("cwd") or "").strip() or _profile_configured_cwd( profile_home ) From c23c370b8b9832a34b4d5d5c39fcdace08dd8f80 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 18 Jun 2026 16:04:58 -0500 Subject: [PATCH 08/28] test: narrow db._conn before raw SQL so ty stops flagging None-union access The new compression-tip tests poke started_at/ended_at directly via db._conn to force deterministic lineage ordering. _conn is typed Optional[Connection], so ty flagged .execute/.commit as unresolved on None. Bind a local and assert it's non-None first to narrow the union. --- .../test_resolve_resume_session_id.py | 16 ++++++++++------ tests/test_tui_gateway_server.py | 8 +++++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/hermes_state/test_resolve_resume_session_id.py b/tests/hermes_state/test_resolve_resume_session_id.py index b4dd8717a2ed..ded2b8fdf534 100644 --- a/tests/hermes_state/test_resolve_resume_session_id.py +++ b/tests/hermes_state/test_resolve_resume_session_id.py @@ -100,9 +100,11 @@ def test_follows_compression_tip_when_parent_retains_messages(db): db.append_message("cont", role="assistant", content="post-compression reply") # Force deterministic ordering so the continuation's started_at is clearly # at/after the parent's ended_at (the get_compression_tip discriminator). - db._conn.execute("UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = 'root'", (base, base + 50)) - db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont'", (base + 100,)) - db._conn.commit() + conn = db._conn + assert conn is not None + conn.execute("UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = 'root'", (base, base + 50)) + conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont'", (base + 100,)) + conn.commit() assert db.resolve_resume_session_id("root") == "cont" @@ -116,9 +118,11 @@ def test_compression_tip_not_confused_with_delegation_child(db): db.append_message("conv", role="user", content="parent turn") db.create_session("subagent", source="cli", parent_session_id="conv") db.append_message("subagent", role="assistant", content="delegated work") - db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'conv'", (base,)) - db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'subagent'", (base + 100,)) - db._conn.commit() + conn = db._conn + assert conn is not None + conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'conv'", (base,)) + conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'subagent'", (base + 100,)) + conn.commit() assert db.resolve_resume_session_id("conv") == "conv" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 6159dab0c166..d2057c634cd3 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -973,12 +973,14 @@ def test_session_resume_follows_compression_tip(monkeypatch, tmp_path): db.end_session("parent_root", "compression") db.create_session("cont_tip", source="tui", parent_session_id="parent_root") db.append_message("cont_tip", role="assistant", content="post-compression reply") - db._conn.execute( + conn = db._conn + assert conn is not None + conn.execute( "UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = 'parent_root'", (base, base + 50), ) - db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont_tip'", (base + 100,)) - db._conn.commit() + conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont_tip'", (base + 100,)) + conn.commit() captured = {} From f8d8f045facce40351f8a34421764fe514c49c0b Mon Sep 17 00:00:00 2001 From: flooryyyy <67979730+flooryyyy@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:04:04 +0100 Subject: [PATCH 09/28] feat(kanban): auto-subscribe calling session on kanban_create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a worker calls kanban_create from inside a session that has a persistent delivery channel, the originating session is now subscribed to the new task's completion/block events automatically. The agent that dispatched the task gets notified instead of having to poll. - Gateway sessions (telegram/discord/slack): HERMES_SESSION_PLATFORM + HERMES_SESSION_CHAT_ID ContextVars, set by the messaging gateway. - TUI / desktop sessions: HERMES_SESSION_KEY in the subprocess env. The TUI notification poller keys on platform='tui' + chat_id=. - CLI / cron / test: no persistent channel, no subscription. Gated by kanban.auto_subscribe_on_create in config.yaml (default True). Disable to mirror pre-feature behaviour — users who want explicit kanban_notify-subscribe calls per task can set it to false. This config gate addresses the design concern that got PR #19718 reverted upstream (unconditional implicit auto-subscribe on tool-driven kanban_create was too aggressive for orchestrator users). HERMES_SESSION_ID is intentionally not a fallback channel — it is set by ACP/agent subprocess telemetry for every invocation, not just TUI, so treating it as a notification target would auto-subscribe every CLI session and re-introduce the over-eager behaviour. The kanban_create response now includes a 'subscribed' bool so orchestrators can react if subscription failed (e.g. by falling back to explicit kanban_notify-subscribe or to polling). Includes 6 tests covering the gateway / TUI / CLI / partial-context / gated / add_notify_sub-failure paths. All 90 tests in test_kanban_tools.py pass; 509 broader kanban tests pass. --- hermes_cli/config.py | 15 ++ tests/tools/test_kanban_tools.py | 190 ++++++++++++++++++ tools/kanban_tools.py | 99 +++++++++ website/docs/user-guide/features/kanban.md | 1 + .../current/user-guide/features/kanban.md | 1 + 5 files changed, 306 insertions(+) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 6db1130b5cd1..c6975d39fe6b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1271,6 +1271,21 @@ def _ensure_hermes_home_managed(home: Path): # global threshold regardless. }, + # Kanban subsystem (orchestrator workers + dispatcher-driven child tasks). + # See tools/kanban_tools.py and hermes_cli/kanban_db.py for the actual + # implementations. Per-platform notification opt-out is handled by the + # kanban dashboard (see ``hermes dashboard`` -> Notifications). + "kanban": { + # Auto-subscribe the originating gateway/TUI session to task + # completion + block events when ``kanban_create`` is called from + # inside a session that has a persistent delivery channel. The + # agent that dispatched the task will get notified automatically + # instead of having to poll. Disable to mirror pre-feature + # behaviour — e.g. for a profile that prefers explicit + # ``kanban_notify-subscribe`` calls per task. + "auto_subscribe_on_create": True, + }, + # Anthropic prompt caching (Claude via OpenRouter or native Anthropic API). # cache_ttl must be "5m" or "1h" (Anthropic-supported tiers); other values are ignored. "prompt_caching": { diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index 2bf89449905a..e9b41f812bb6 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -1812,3 +1812,193 @@ def test_board_param_in_all_schemas(): assert "board" not in schema["parameters"].get("required", []), ( f"{schema['name']} marks board as required; must be optional" ) + + +# --------------------------------------------------------------------------- +# kanban_create auto-subscribe behaviour +# +# When a worker calls kanban_create from inside a session that has a +# persistent delivery channel, the originating session should be +# subscribed to the new task's completion/block events automatically. +# - Gateway sessions: HERMES_SESSION_PLATFORM + HERMES_SESSION_CHAT_ID set. +# - TUI sessions: HERMES_SESSION_KEY (or HERMES_SESSION_ID) set, with +# the platform/chat_id ContextVars intentionally empty. +# - CLI / cron / test sessions: no delivery channel -> no subscription. +# - Config gate kanban.auto_subscribe_on_create: false -> no subscription +# even when the session has a delivery channel. +# --------------------------------------------------------------------------- + +def _list_subs_for_task(task_id): + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + return list(kb.list_notify_subs(conn, task_id)) + finally: + conn.close() + + +def _sub_index(subs): + """Normalise a list of notify-subs (dicts or objects) into dicts + keyed by platform+chat_id, so assertions work regardless of the + return shape.""" + out = [] + for s in subs: + if isinstance(s, dict): + out.append(s) + else: + out.append({ + "platform": getattr(s, "platform", None), + "chat_id": getattr(s, "chat_id", None), + "thread_id": getattr(s, "thread_id", None), + "user_id": getattr(s, "user_id", None), + }) + return out + + +def test_create_subscribes_gateway_session(monkeypatch, worker_env): + """A gateway session (platform + chat_id set) gets auto-subscribed + to its own kanban_create result, and the response surfaces the + ``subscribed`` flag so the orchestrator can react.""" + from tools import kanban_tools as kt + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "chat-42") + monkeypatch.setenv("HERMES_SESSION_THREAD_ID", "thread-7") + monkeypatch.setenv("HERMES_SESSION_USER_ID", "user-9") + + out = kt._handle_create({ + "title": "auto-sub gateway", + "assignee": "peer", + }) + d = json.loads(out) + assert d["ok"] is True + new_tid = d["task_id"] + assert d["subscribed"] is True, d + + subs = _sub_index(_list_subs_for_task(new_tid)) + assert len(subs) == 1 + s = subs[0] + assert s["platform"] == "telegram" + assert s["chat_id"] == "chat-42" + assert s["thread_id"] == "thread-7" + assert s["user_id"] == "user-9" + + +def test_create_subscribes_tui_session_via_session_key(monkeypatch, worker_env): + """TUI / desktop sessions don't have a platform/chat_id (single + local channel), but the parent process exports HERMES_SESSION_KEY. + We should still auto-subscribe, with platform='tui' and + chat_id=.""" + from tools import kanban_tools as kt + monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) + monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False) + monkeypatch.delenv("HERMES_SESSION_THREAD_ID", raising=False) + monkeypatch.delenv("HERMES_SESSION_USER_ID", raising=False) + monkeypatch.setenv("HERMES_SESSION_KEY", "tui-session-abc") + monkeypatch.delenv("HERMES_SESSION_ID", raising=False) + + out = kt._handle_create({ + "title": "auto-sub tui", + "assignee": "peer", + }) + d = json.loads(out) + assert d["ok"] is True + new_tid = d["task_id"] + assert d["subscribed"] is True, d + + subs = _sub_index(_list_subs_for_task(new_tid)) + assert len(subs) == 1 + assert subs[0]["platform"] == "tui" + assert subs[0]["chat_id"] == "tui-session-abc" + + +def test_create_does_not_subscribe_in_cli_session(monkeypatch, worker_env): + """CLI / cron / test sessions have no persistent delivery channel. + _maybe_auto_subscribe returns False and no row is written.""" + from tools import kanban_tools as kt + monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) + monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False) + monkeypatch.delenv("HERMES_SESSION_KEY", raising=False) + monkeypatch.delenv("HERMES_SESSION_ID", raising=False) + + out = kt._handle_create({ + "title": "no sub cli", + "assignee": "peer", + }) + d = json.loads(out) + assert d["ok"] is True + assert d["subscribed"] is False, d + + assert _list_subs_for_task(d["task_id"]) == [] + + +def test_create_respects_auto_subscribe_on_create_false(monkeypatch, worker_env, tmp_path): + """The config gate kanban.auto_subscribe_on_create=false must + suppress auto-subscription even when the session has a delivery + channel. This is the knob that addresses the upstream design + concern from PR #19718 (reverted in #19721) — users who want + explicit kanban_notify-subscribe calls per task get that.""" + # worker_env already created /.hermes; use a fresh sibling + # home to avoid mkdir() colliding with the worker's directory. + home = tmp_path / "gate-home" / ".hermes" + home.mkdir(parents=True) + (home / "config.yaml").write_text( + "kanban:\n auto_subscribe_on_create: false\n" + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "channel-1") + + from tools import kanban_tools as kt + out = kt._handle_create({ + "title": "no sub gated", + "assignee": "peer", + }) + d = json.loads(out) + assert d["ok"] is True + assert d["subscribed"] is False, d + + assert _list_subs_for_task(d["task_id"]) == [] + + +def test_create_partial_session_context_no_subscribe(monkeypatch, worker_env): + """Only one of (platform, chat_id) set -> no implicit subscribe. + Either both are set (gateway) or neither (TUI / CLI); partial is + ambiguous and the safe default is to skip.""" + from tools import kanban_tools as kt + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "slack") + monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False) + monkeypatch.delenv("HERMES_SESSION_KEY", raising=False) + monkeypatch.delenv("HERMES_SESSION_ID", raising=False) + + out = kt._handle_create({ + "title": "no sub partial", + "assignee": "peer", + }) + d = json.loads(out) + assert d["ok"] is True + assert d["subscribed"] is False, d + + +def test_maybe_auto_subscribe_swallows_add_notify_sub_failure(monkeypatch, worker_env): + """If add_notify_sub itself raises (e.g. DB locked, schema drift), + _maybe_auto_subscribe must NOT bubble that up and fail the parent + kanban_create. The function returns False and the parent create + still succeeds with subscribed=False.""" + from tools import kanban_tools as kt + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "chat-42") + + from hermes_cli import kanban_db as kb + + def _boom(*a, **kw): + raise RuntimeError("simulated DB failure") + + monkeypatch.setattr(kb, "add_notify_sub", _boom) + + out = kt._handle_create({ + "title": "auto-sub tolerates add_notify_sub failure", + "assignee": "peer", + }) + d = json.loads(out) + assert d["ok"] is True, d + assert d["subscribed"] is False, d diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 67157dfc1c62..15988bcba897 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -34,6 +34,7 @@ from typing import Any, Optional from tools.registry import registry, tool_error +from hermes_cli.config import cfg_get, load_config logger = logging.getLogger(__name__) @@ -818,9 +819,11 @@ def _handle_create(args: dict, **kw) -> str: session_id=session_id, ) new_task = kb.get_task(conn, new_tid) + subscribed = _maybe_auto_subscribe(conn, new_tid) return _ok( task_id=new_tid, status=new_task.status if new_task else None, + subscribed=subscribed, ) finally: conn.close() @@ -831,6 +834,102 @@ def _handle_create(args: dict, **kw) -> str: return tool_error(f"kanban_create: {e}") +def _maybe_auto_subscribe(conn: Any, task_id: str) -> bool: + """Auto-subscribe the calling session to task completion / block events. + + Returns True if a subscription row was written, False otherwise (no + session context, config gate disabled, or best-effort failure). The + caller surfaces this in the ``subscribed`` field of the kanban_create + response so an orchestrator can decide whether to fall back to an + explicit ``kanban_notify-subscribe`` or to polling. + + Gated by ``kanban.auto_subscribe_on_create`` in config.yaml (default + True). Disable to mirror pre-feature behaviour, e.g. when the + originating user/chat opted out via the per-platform notification + toggle (see ``hermes dashboard``). + + Subscription paths: + + - **Gateway** (telegram/discord/slack/etc): ``HERMES_SESSION_PLATFORM`` + and ``HERMES_SESSION_CHAT_ID`` are set in ContextVars by the + messaging gateway before agent dispatch. The notification poller + already keys off these, so we just register a row. + + - **TUI** (herm desktop / herm TUI): the platform/chat_id ContextVars + are intentionally cleared (TUI is a single-channel local UI, not + a multi-tenant chat surface), but the agent subprocess inherits + ``HERMES_SESSION_KEY`` from the parent session. We subscribe with + ``platform="tui"`` and ``chat_id=``; the TUI notification + poller (``tui_gateway/server.py``) reads ``kanban_notify_subs`` + for these rows and posts the completion message into the running + session. + + - **CLI / cron / test / unattached**: no persistent delivery channel, + no-op. + + Failure mode: any exception inside the function is logged at WARNING + with the offending exception + diagnostic env vars and swallowed. + We never want a notification bookkeeping failure to fail the + kanban_create that the agent is mid-conversation about. + """ + try: + cfg = load_config() + if not cfg_get(cfg, "kanban", "auto_subscribe_on_create", default=True): + return False + except Exception: + # If config can't load we still default to True — this is the + # user-friendly behaviour that mirrors the pre-gate implementation. + pass + + platform = "" + chat_id = "" + try: + from gateway.session_context import get_session_env + platform = get_session_env("HERMES_SESSION_PLATFORM", "") + chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "") + if not platform or not chat_id: + # TUI / desktop fallback: platform/chat_id ContextVars are + # cleared for TUI sessions, but the parent process exports + # HERMES_SESSION_KEY into the subprocess env. Treat that + # as a "tui" subscription so the TUI notification poller + # (tui_gateway/server.py) can pick it up. + # + # HERMES_SESSION_ID is intentionally NOT a fallback here: + # it is set by ACP / the agent subprocess for telemetry + # regardless of whether the parent is a TUI or a CLI, so + # treating it as a notification target would auto-subscribe + # every CLI invocation, which is exactly the over-eager + # behaviour that got #19718 reverted upstream. The TUI + # poller keys on HERMES_SESSION_KEY. + session_key = ( + get_session_env("HERMES_SESSION_KEY", "") + or os.environ.get("HERMES_SESSION_KEY", "") + ) + if not session_key: + return False # CLI / cron / test — no persistent channel + platform = "tui" + chat_id = session_key + thread_id = get_session_env("HERMES_SESSION_THREAD_ID", "") or None + user_id = get_session_env("HERMES_SESSION_USER_ID", "") or None + notifier_profile = os.environ.get("HERMES_PROFILE") + + # Lazy-import to keep the module-level dependency light + from hermes_cli import kanban_db as _kb + _kb.add_notify_sub( + conn, task_id=task_id, + platform=platform, chat_id=chat_id, + thread_id=thread_id, user_id=user_id, + notifier_profile=notifier_profile, + ) + return True + except Exception as _exc: + logger.warning( + "_maybe_auto_subscribe failed: %r (platform=%r key_set=%r)", + _exc, platform, bool(chat_id), + ) + return False + + def _handle_unblock(args: dict, **kw) -> str: """Transition a blocked task back to ready.""" guard = _require_orchestrator_tool("kanban_unblock") diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index d59438a7171e..66a1ac0be908 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -535,6 +535,7 @@ Config knobs (all under `kanban:` in `~/.hermes/config.yaml`): | `auto_decompose_per_tick` | `3` | Cap on decompositions per dispatcher tick. Excess defers to the next tick. | | `orchestrator_profile` | `""` | Profile assigned to the root/orchestration task after decomposition. Empty = fall back to active default profile. | | `default_assignee` | `""` | Where a child task lands when the LLM picks an unknown profile. Empty = fall back to active default. | +| `auto_subscribe_on_create` | `true` | When a worker calls `kanban_create` from inside a session with a persistent delivery channel (messaging gateway or TUI), the originating session is auto-subscribed to the new task's completion/block events. The dispatcher still drives the delivery — this only changes whether the caller's chat/key shows up in the notify-sub table. Set to `false` to require explicit `kanban_notify-subscribe` calls per task. | And the two auxiliary LLM slots: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md index 3c5878c089ab..febeb213c7ba 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md @@ -431,6 +431,7 @@ hermes dashboard # 导航栏中出现 "Kanban" 标签页,位于 "Skills | `auto_decompose_per_tick` | `3` | 每个调度器 tick 的分解上限。超出部分推迟到下一个 tick。 | | `orchestrator_profile` | `""` | 拥有分解权的配置文件。空 = 回退到活动默认配置文件。 | | `default_assignee` | `""` | LLM 选择未知配置文件时子任务的落地位置。空 = 回退到活动默认配置文件。 | +| `auto_subscribe_on_create` | `true` | 当 worker 在具有持久投递通道的会话(消息网关或 TUI)内调用 `kanban_create` 时,原始会话会自动订阅新任务的完成/阻塞事件。调度器仍负责驱动投递 —— 此设置只决定调用者的聊天/密钥是否出现在通知订阅表中。设为 `false` 则要求对每个任务显式调用 `kanban_notify-subscribe`。 | 以及两个辅助 LLM 槽: From 2944b3c394e3fe56cadbadd073a7fa54b24f3ba5 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Thu, 18 Jun 2026 16:16:06 -0500 Subject: [PATCH 10/28] fix(desktop): make session delete idempotent and id-resolving (#48641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELETE /api/sessions/{id} was the only session endpoint that didn't resolve the id (detail, messages, rename, export all call resolve_session_id) and 404'd when the row was already gone. The desktop optimistically removes the sidebar row, then RESTORES it and shows the error on any failure — so deleting a session that had just been reaped (empty-session hygiene) or removed by a concurrent client resurrected a ghost row and surfaced "session not found". /goal + auto-compression churn leaves transient empty rows that race the sidebar snapshot, which is the exact "I deleted the empty one and got 'session not found'" report. Resolve exact ids / unique prefixes, and treat an already-absent session as an idempotent success — DELETE's contract is "ensure it's gone". This mirrors the bulk-delete endpoint, which already treats ghost ids as success. Tests: deleting an absent id is idempotent (200, not 404); delete resolves a unique prefix; a real session still deletes. --- hermes_cli/web_server.py | 15 +++++- tests/hermes_cli/test_web_server.py | 73 +++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index c8fe020fe151..9f451ddfd0ce 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -6886,8 +6886,19 @@ async def delete_session_endpoint(session_id: str, profile: Optional[str] = None # desktop routes their DELETE to the remote backend. Omit for current/default. db = _open_session_db_for_profile(profile) try: - if not db.delete_session(session_id): - raise HTTPException(status_code=404, detail="Session not found") + # Resolve exact ids / unique prefixes like every other session endpoint + # (detail, messages, rename, export all do). A session that no longer + # exists is an idempotent success: DELETE's contract is "ensure it's + # gone", and the desktop optimistically removes the row then RESTORES it + # on any error — so a 404 on an already-absent row resurrected a ghost + # row and surfaced "session not found". /goal + auto-compression churn + # leaves transient empty rows (reaped by empty-session hygiene) that + # race the sidebar snapshot, which is exactly when this fired. Mirrors + # the bulk-delete endpoint, which already treats ghost ids as success. + sid = db.resolve_session_id(session_id) + if not sid: + return {"ok": True, "already_absent": True} + db.delete_session(sid) return {"ok": True} finally: db.close() diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 8f6842b6b503..4312cc08152d 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4334,6 +4334,79 @@ def test_component_styles_accepts_numeric_values(self): assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"} +class TestDeleteSessionEndpoint: + """Tests for ``DELETE /api/sessions/{session_id}`` — the single-row delete + behind the desktop sidebar's per-session delete. + + The desktop optimistically removes the row, then RESTORES it on any error + and surfaces the message. So a 404 on a row that is already gone (reaped by + empty-session hygiene, or removed by a concurrent client — both common amid + /goal + auto-compression churn that leaves transient empty rows) resurrected + a ghost row and showed "session not found". DELETE must be idempotent and + resolve ids like every other session endpoint. + """ + + @pytest.fixture(autouse=True) + def _setup_test_client(self, monkeypatch, _isolate_hermes_home): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + + import hermes_state + from hermes_constants import get_hermes_home + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN + + monkeypatch.setattr( + hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db" + ) + + self.auth_client = TestClient(app) + self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + + def _seed(self, ids): + from hermes_state import SessionDB + + db = SessionDB() + try: + for sid in ids: + db.create_session(session_id=sid, source="cli") + finally: + db.close() + + def _exists(self, sid) -> bool: + from hermes_state import SessionDB + + db = SessionDB() + try: + return db.get_session(sid) is not None + finally: + db.close() + + def test_delete_existing_session(self): + self._seed(["real_one"]) + resp = self.auth_client.delete("/api/sessions/real_one") + assert resp.status_code == 200 + assert resp.json().get("ok") is True + assert not self._exists("real_one") + + def test_delete_absent_session_is_idempotent(self): + # PREMISE / regression: deleting a row that no longer exists must NOT + # 404 — the desktop would resurrect the ghost row and show + # "session not found". DELETE's contract is "ensure it's gone". + resp = self.auth_client.delete("/api/sessions/never_existed") + assert resp.status_code == 200 + assert resp.json().get("ok") is True + + def test_delete_resolves_unique_prefix(self): + # Symmetry with the other session endpoints, which all resolve ids. + self._seed(["20260618_abcdef_unique"]) + resp = self.auth_client.delete("/api/sessions/20260618_abcdef") + assert resp.status_code == 200 + assert resp.json().get("ok") is True + assert not self._exists("20260618_abcdef_unique") + + class TestBulkDeleteSessionsEndpoint: """Tests for ``POST /api/sessions/bulk-delete`` — backs the dashboard's "Delete N selected" flow on the sessions page. From 3ead2bdd0d92083dc11fc49f9260183c2a0d79cd Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Thu, 18 Jun 2026 19:19:48 +0300 Subject: [PATCH 11/28] feat(prompt): configurable per-platform system-prompt hint overrides Add platform_hints config so an admin can append to or replace Hermes' built-in platform hint for a single messaging platform (WhatsApp, Slack, Telegram, ...) without affecting other platforms. Enables enterprise managed profiles to steer platform-aware skills (e.g. invoke a custom table-formatting skill on WhatsApp where Markdown tables don't render) while leaving Telegram/Slack/CLI behavior unchanged. - hermes_cli/config.py: document platform_hints in DEFAULT_CONFIG - agent/agent_init.py: load platform_hints -> agent._platform_hint_overrides - agent/system_prompt.py: _resolve_platform_hint() applies append/replace (replace wins; bare string = append shorthand); defensive on bad config - tests: 16 cases covering append/replace/shorthand/isolation/malformed Override only affects the platform-hint segment of the system prompt; SOUL/context/memory tiers and general instructions are unchanged. --- agent/agent_init.py | 17 ++++ agent/system_prompt.py | 60 +++++++++++- hermes_cli/config.py | 16 ++++ scripts/release.py | 1 + tests/agent/test_platform_hint_overrides.py | 101 ++++++++++++++++++++ 5 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 tests/agent/test_platform_hint_overrides.py diff --git a/agent/agent_init.py b/agent/agent_init.py index 4210b515f65a..555f930f559d 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1239,6 +1239,23 @@ def init_agent( # are noisy. agent._environment_probe = bool(_agent_section.get("environment_probe", True)) + # Per-platform prompt-hint overrides (config.yaml → platform_hints). + # Lets an enterprise admin append to or replace Hermes' built-in + # platform hint for a single messaging platform (e.g. WhatsApp) without + # affecting other platforms. Shape: + # platform_hints: + # whatsapp: + # append: "When tabular output would help, invoke the ... skill." + # slack: + # replace: "Custom Slack hint that fully replaces the default." + # Stored verbatim; resolution happens in agent/system_prompt.py against + # the active platform. Invalid shapes are ignored defensively so a bad + # config entry can never break prompt assembly. + _platform_hints_cfg = _agent_cfg.get("platform_hints", {}) + if not isinstance(_platform_hints_cfg, dict): + _platform_hints_cfg = {} + agent._platform_hint_overrides = _platform_hints_cfg + # App-level API retry count (wraps each model API call). Default 3, # overridable via agent.api_max_retries in config.yaml. See #11616. try: diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 281f01399b42..d8eaea4e39ef 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -61,6 +61,55 @@ def _ra(): return run_agent +def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) -> str: + """Apply a per-platform prompt-hint override to the default hint. + + Reads ``agent._platform_hint_overrides`` (populated from + ``config.yaml`` ``platform_hints`` by ``agent_init``) and resolves the + effective hint for *platform_key*: + + * ``replace`` — substitute the default hint entirely. + * ``append`` — keep the default and append the extra text. + * a bare string value — treated as ``append`` (convenience shorthand). + + Precedence: ``replace`` wins over ``append`` if both are present. + Override text is added on top of (not instead of) the SOUL/context/ + memory tiers — it only affects the platform-hint segment, so other + platforms are unaffected and general system instructions still apply. + + Defensive: any malformed entry falls back to the unmodified default so + a bad config value can never break prompt assembly or leak across + platforms. + """ + if not platform_key: + return default_hint + overrides = getattr(agent, "_platform_hint_overrides", None) + if not isinstance(overrides, dict) or not overrides: + return default_hint + spec = overrides.get(platform_key) + if spec is None: + return default_hint + + # Shorthand: a bare string is treated as append text. + if isinstance(spec, str): + extra = spec.strip() + return f"{default_hint}\n\n{extra}".strip() if extra else default_hint + + if not isinstance(spec, dict): + return default_hint + + replace_text = spec.get("replace") + if isinstance(replace_text, str) and replace_text.strip(): + base = replace_text.strip() + else: + base = default_hint + + append_text = spec.get("append") + if isinstance(append_text, str) and append_text.strip(): + return f"{base}\n\n{append_text.strip()}".strip() + return base + + def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]: """Assemble the system prompt as three ordered parts. @@ -331,18 +380,25 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) ) platform_key = (agent.platform or "").lower().strip() + # Resolve the built-in/plugin default hint for this platform, then apply + # any per-platform override from config (platform_hints.). + _default_hint = "" if platform_key in PLATFORM_HINTS: - stable_parts.append(PLATFORM_HINTS[platform_key]) + _default_hint = PLATFORM_HINTS[platform_key] elif platform_key: # Check plugin registry for platform-specific LLM guidance try: from gateway.platform_registry import platform_registry _entry = platform_registry.get(platform_key) if _entry and _entry.platform_hint: - stable_parts.append(_entry.platform_hint) + _default_hint = _entry.platform_hint except Exception: pass + _effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint) + if _effective_hint: + stable_parts.append(_effective_hint) + # ── Context tier (cwd-dependent, may change between sessions) ─ context_parts: List[str] = [] diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c6975d39fe6b..f698c11d5ac9 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2170,6 +2170,22 @@ def _ensure_hermes_home_managed(home: Path): # User-defined quick commands that bypass the agent loop (type: exec only) "quick_commands": {}, + # Per-platform system-prompt hint overrides. Lets an admin append to or + # replace Hermes' built-in platform hint for a single messaging platform + # (WhatsApp, Slack, Telegram, ...) without affecting other platforms. + # Useful for enterprise/managed profiles that ship platform-aware skills. + # Each key is a platform name; the value is either: + # { "append": "extra text" } — keep the default hint, append text + # { "replace": "full text" } — substitute the default hint entirely + # "extra text" — shorthand for { "append": ... } + # `replace` wins over `append` if both are given. Example: + # platform_hints: + # whatsapp: + # append: > + # When tabular output would be useful, invoke the + # table_formatting skill instead of emitting a Markdown table. + "platform_hints": {}, + # Shell-script hooks — declarative bridge that invokes shell scripts # on plugin-hook events (pre_tool_call, post_tool_call, pre_llm_call, # subagent_stop, etc.). Each entry maps an event name to a list of diff --git a/scripts/release.py b/scripts/release.py index 79ecf36382ad..6f56a14154d5 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "victor@rocketfueldev.com": "victor-kyriazakos", "286497132+srojk34@users.noreply.github.com": "srojk34", "59806492+sitkarev@users.noreply.github.com": "sitkarev", "zheng@omegasys.eu": "omegazheng", diff --git a/tests/agent/test_platform_hint_overrides.py b/tests/agent/test_platform_hint_overrides.py new file mode 100644 index 000000000000..fe34669ee03f --- /dev/null +++ b/tests/agent/test_platform_hint_overrides.py @@ -0,0 +1,101 @@ +"""Tests for per-platform prompt-hint overrides (config.yaml → platform_hints). + +Covers agent/system_prompt.py::_resolve_platform_hint — the resolver that +applies append/replace overrides to a platform's default hint. Feature added +for enterprise managed profiles (per-platform behavior without affecting other +platforms). See HA Core ticket: configurable per-platform prompt hints. +""" + +import types + +from agent.system_prompt import _resolve_platform_hint + + +def _agent(overrides): + """Minimal stand-in carrying just the override attribute the resolver reads.""" + a = types.SimpleNamespace() + a._platform_hint_overrides = overrides + return a + + +DEFAULT = "You are on WhatsApp. Do not use markdown." +EXTRA = "When tabular output would help, invoke the table_formatting skill." + + +class TestResolvePlatformHint: + def test_no_overrides_returns_default(self): + assert _resolve_platform_hint(_agent({}), "whatsapp", DEFAULT) == DEFAULT + + def test_missing_attr_returns_default(self): + a = types.SimpleNamespace() # no _platform_hint_overrides at all + assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT + + def test_platform_not_in_overrides_returns_default(self): + a = _agent({"slack": {"append": "x"}}) + assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT + + def test_append_dict(self): + a = _agent({"whatsapp": {"append": EXTRA}}) + out = _resolve_platform_hint(a, "whatsapp", DEFAULT) + assert out == f"{DEFAULT}\n\n{EXTRA}" + assert DEFAULT in out and EXTRA in out + + def test_replace_dict(self): + a = _agent({"whatsapp": {"replace": EXTRA}}) + out = _resolve_platform_hint(a, "whatsapp", DEFAULT) + assert out == EXTRA + assert DEFAULT not in out + + def test_replace_wins_over_append_but_both_applied(self): + a = _agent({"whatsapp": {"replace": "BASE", "append": "TAIL"}}) + out = _resolve_platform_hint(a, "whatsapp", DEFAULT) + # replace substitutes the base, append still tacks on + assert out == "BASE\n\nTAIL" + assert DEFAULT not in out + + def test_bare_string_is_append_shorthand(self): + a = _agent({"whatsapp": EXTRA}) + out = _resolve_platform_hint(a, "whatsapp", DEFAULT) + assert out == f"{DEFAULT}\n\n{EXTRA}" + + def test_other_platform_unaffected(self): + """An override for whatsapp must not change telegram's hint.""" + a = _agent({"whatsapp": {"append": EXTRA}}) + tg_default = "You are on Telegram. Markdown works." + assert _resolve_platform_hint(a, "telegram", tg_default) == tg_default + + def test_empty_platform_key_returns_default(self): + a = _agent({"whatsapp": {"append": EXTRA}}) + assert _resolve_platform_hint(a, "", DEFAULT) == DEFAULT + + # --- defensive / malformed input: never break prompt assembly --- + + def test_malformed_spec_list_returns_default(self): + a = _agent({"whatsapp": ["not", "valid"]}) + assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT + + def test_overrides_not_a_dict_returns_default(self): + a = _agent(["nope"]) + assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT + + def test_empty_append_string_returns_default(self): + a = _agent({"whatsapp": {"append": " "}}) + assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT + + def test_empty_replace_falls_back_to_default_base(self): + a = _agent({"whatsapp": {"replace": " "}}) + assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT + + def test_non_string_append_ignored(self): + a = _agent({"whatsapp": {"append": 123}}) + assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT + + def test_replace_with_empty_default_hint(self): + """replace works even when the platform had no built-in default.""" + a = _agent({"customplat": {"replace": "Custom hint."}}) + assert _resolve_platform_hint(a, "customplat", "") == "Custom hint." + + def test_append_with_empty_default_hint(self): + """append on a platform with no default just yields the extra text.""" + a = _agent({"customplat": {"append": "Only this."}}) + assert _resolve_platform_hint(a, "customplat", "") == "Only this." From f1ff8459dbc1135f77c1d113607b80a3f75c81b2 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:49:03 -0700 Subject: [PATCH 12/28] docs(prompt): document platform_hints config override Adds a 'Customizing platform hints' section to the Prompt Assembly developer guide covering the append/replace/shorthand shapes, the defensive fallback, and the cache-stable lifecycle (stable tier, resolved at build time). --- .../docs/developer-guide/prompt-assembly.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/website/docs/developer-guide/prompt-assembly.md b/website/docs/developer-guide/prompt-assembly.md index d255c4a2e936..aabd08562423 100644 --- a/website/docs/developer-guide/prompt-assembly.md +++ b/website/docs/developer-guide/prompt-assembly.md @@ -116,6 +116,43 @@ You are a CLI AI Agent. Try not to use markdown but simple text renderable inside a terminal. ``` +## Customizing platform hints + +The platform hint (Layer 10 above) is the per-surface guidance Hermes +injects for Telegram, WhatsApp, Slack, CLI, and other platforms — for +example "you are on a terminal, avoid Markdown." The built-in defaults +live in `PLATFORM_HINTS` (`agent/system_prompt.py`); plugin-provided +platforms supply theirs through the platform registry. + +An administrator can append to or replace a single platform's hint from +`config.yaml` via the top-level `platform_hints` key, without touching +any other platform: + +```yaml +platform_hints: + whatsapp: + append: > + When tabular output would be useful, invoke the table_formatting + skill instead of emitting a Markdown table. + slack: + replace: "You are on Slack. Keep responses tight and avoid wide tables." + telegram: "Prefer short messages; split long answers." # shorthand = append +``` + +- `append` — keep the built-in hint and add the extra text after it. +- `replace` — substitute the built-in hint entirely. +- A bare string — shorthand for `append`. +- `replace` wins over `append` when both are present. +- A malformed entry is ignored defensively and falls back to the + unmodified default, so a bad config value can never break prompt + assembly or leak across platforms. + +The override is resolved when the system prompt is built (session start, +and again on compaction since that rebuilds the prompt). It produces a +byte-stable hint for a fixed config, so it lives in the **stable** tier +alongside the built-in hint and does not break prompt caching — it is +not a live mid-session mutation of a frozen prompt. + ## How SOUL.md appears in the prompt `SOUL.md` lives at `~/.hermes/SOUL.md` and serves as the agent's identity — the very first section of the system prompt. The loading logic in `prompt_builder.py` works as follows: From 769f307042d22be2c092249c2d8d78f85fea8e37 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 18 Jun 2026 17:41:58 -0400 Subject: [PATCH 13/28] fix(npm): lock react-simple-icons to 13.11.1 suppress annoying message about engines that's completely benign but people seem to complain --- apps/desktop/package.json | 2 +- package-lock.json | 24 ++++++++++-------------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 70d35fb7bb08..c1d2290e4cb4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -55,7 +55,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hermes/shared": "file:../shared", - "@icons-pack/react-simple-icons": "^13.13.0", + "@icons-pack/react-simple-icons": "=13.11.1", "@nanostores/react": "^1.1.0", "@nous-research/ui": "^0.13.0", "@radix-ui/react-slot": "^1.2.4", diff --git a/package-lock.json b/package-lock.json index 8f95ffeee809..77eafcbaaa15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hermes/shared": "file:../shared", - "@icons-pack/react-simple-icons": "^13.13.0", + "@icons-pack/react-simple-icons": "=13.11.1", "@nanostores/react": "^1.1.0", "@nous-research/ui": "^0.13.0", "@radix-ui/react-slot": "^1.2.4", @@ -173,6 +173,15 @@ "global-agent": "^3.0.0" } }, + "apps/desktop/node_modules/@icons-pack/react-simple-icons": { + "version": "13.11.1", + "resolved": "https://registry.npmjs.org/@icons-pack/react-simple-icons/-/react-simple-icons-13.11.1.tgz", + "integrity": "sha512-WbwN/o7dUHEjDCJh2p3RvDZ4kZ8nhfUSkUSm0bWuPTXIsoKgDJpwD5UkMCG22R/5kZH6lHAZXwuHWsKNtX7fYA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.13 || ^17 || ^18 || ^19" + } + }, "apps/desktop/node_modules/@nous-research/ui": { "version": "0.13.2", "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.13.2.tgz", @@ -2285,19 +2294,6 @@ "import-meta-resolve": "^4.2.0" } }, - "node_modules/@icons-pack/react-simple-icons": { - "version": "13.13.0", - "resolved": "https://registry.npmjs.org/@icons-pack/react-simple-icons/-/react-simple-icons-13.13.0.tgz", - "integrity": "sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g==", - "license": "MIT", - "engines": { - "node": ">=24", - "pnpm": ">=10" - }, - "peerDependencies": { - "react": "^16.13 || ^17 || ^18 || ^19" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", From 03d9a95a74b234c2d46e0b59cf6e12281f93fbf5 Mon Sep 17 00:00:00 2001 From: Ben <62250174+benfrank241@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:48:47 -0400 Subject: [PATCH 14/28] fix(desktop): show Hindsight memory provider (#37546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): show Hindsight memory provider * feat(desktop): configure Hindsight memory provider * fix(desktop): limit Hindsight modes to supported setup * refactor(desktop): generic memory-provider config surface Replace the bespoke Hindsight settings surface with a declarative, schema-driven path so adding a memory provider is pure declaration — no per-provider page, conditional, or endpoint. - memory_providers.py: declarative registry. Each provider lists its fields {key, label, kind, default, options, secret-vs-plain}. Hindsight's mode is a select(cloud, local_external), so rejecting local_embedded falls out of generic enum validation instead of a hand-written check. - One generic endpoint pair GET/PUT /api/memory/providers/{name}/config. GET returns declared fields + current values (secrets only as is_set, never read back); PUT validates selects against their options, writes plain fields to the provider config file, secrets to the env store, and flips memory.provider. - ProviderConfigPanel renders straight from the schema, replacing hindsight-settings.tsx and the memory.provider === 'hindsight' conditional in config-settings.tsx — same pattern as toolset-config-panel.tsx off env_vars. Scoped to memory providers; storage layout is unchanged so the runtime Hindsight plugin reads the same config.json / HINDSIGHT_API_KEY / provider keys as before. Tests cover the registry, endpoint behavior (defaults, write+secret, select rejection, unknown provider, secret-never-returned), and the generic panel. --- .../src/app/settings/config-settings.tsx | 4 + apps/desktop/src/app/settings/constants.ts | 2 +- apps/desktop/src/app/settings/helpers.test.ts | 6 + .../settings/provider-config-panel.test.tsx | 142 ++++++++++++++ .../app/settings/provider-config-panel.tsx | 182 ++++++++++++++++++ apps/desktop/src/hermes.ts | 19 ++ apps/desktop/src/types/hermes.ts | 25 +++ hermes_cli/memory_providers.py | 149 ++++++++++++++ hermes_cli/web_server.py | 163 ++++++++++++++++ tests/hermes_cli/test_memory_providers.py | 46 +++++ tests/hermes_cli/test_web_server.py | 105 +++++++++- 11 files changed, 841 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/app/settings/provider-config-panel.test.tsx create mode 100644 apps/desktop/src/app/settings/provider-config-panel.tsx create mode 100644 hermes_cli/memory_providers.py create mode 100644 tests/hermes_cli/test_memory_providers.py diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 2d550560764a..771ba2836f4e 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -23,6 +23,7 @@ import { fieldCopyForSchemaKey } from './field-copy' import { enumOptionsFor, getNested, prettyName, setNested } from './helpers' import { ModelSettings } from './model-settings' import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives' +import { ProviderConfigPanel } from './provider-config-panel' function ConfigField({ schemaKey, @@ -368,6 +369,9 @@ export function ConfigSettings({ schemaKey={key} value={getNested(config, key)} /> + {key === 'memory.provider' && typeof getNested(config, key) === 'string' && getNested(config, key) ? ( + + ) : null} ))} diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 1cf7cf3ce165..5fc9ba134ccf 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -239,7 +239,7 @@ export const ENUM_OPTIONS: Record = { 'code_execution.mode': ['project', 'strict'], 'context.engine': ['compressor', 'default', 'custom'], 'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'], - 'memory.provider': ['', 'builtin', 'honcho'], + 'memory.provider': ['', 'builtin', 'hindsight', 'honcho'], // Terminal execution backends — kept in sync with the dispatch ladder in // tools/terminal_tool.py::_create_environment (local/docker/singularity/ // modal/daytona/ssh). Remote backends need extra env (image, tokens, host). diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index b65d63d3296b..1a8d0eba994f 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -6,6 +6,12 @@ import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers' describe('settings helpers', () => { + it('lists Hindsight as a built-in desktop memory provider option', () => { + const options = enumOptionsFor('memory.provider', '', {}) + + expect(options).toContain('hindsight') + }) + describe('defineFieldCopy', () => { it('flattens nested field copy paths', () => { const copy = defineFieldCopy({ diff --git a/apps/desktop/src/app/settings/provider-config-panel.test.tsx b/apps/desktop/src/app/settings/provider-config-panel.test.tsx new file mode 100644 index 000000000000..3f3d98f1520f --- /dev/null +++ b/apps/desktop/src/app/settings/provider-config-panel.test.tsx @@ -0,0 +1,142 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { MemoryProviderConfig } from '@/types/hermes' + +const getMemoryProviderConfig = vi.fn() +const saveMemoryProviderConfig = vi.fn() + +vi.mock('@/hermes', () => ({ + getMemoryProviderConfig: (provider: string) => getMemoryProviderConfig(provider), + saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values) +})) + +vi.mock('@/store/notifications', () => ({ + notify: vi.fn(), + notifyError: vi.fn() +})) + +function hindsightSchema(overrides: Partial[] = []): MemoryProviderConfig { + const fields: MemoryProviderConfig['fields'] = [ + { + key: 'mode', + label: 'Mode', + kind: 'select', + value: 'cloud', + description: 'How Hermes connects to Hindsight.', + placeholder: '', + is_set: true, + options: [ + { value: 'cloud', label: 'Cloud', description: 'Hindsight Cloud API (lightweight, just needs an API key)' }, + { value: 'local_external', label: 'Local External', description: 'Connect to an existing Hindsight instance' } + ] + }, + { + key: 'api_key', + label: 'API key', + kind: 'secret', + value: '', + description: 'Used to authenticate with the Hindsight API.', + placeholder: 'Enter Hindsight API key', + is_set: false, + options: [] + }, + { + key: 'api_url', + label: 'API URL', + kind: 'text', + value: 'https://api.hindsight.vectorize.io', + description: '', + placeholder: '', + is_set: true, + options: [] + }, + { key: 'bank_id', label: 'Bank ID', kind: 'text', value: 'hermes', description: '', placeholder: '', is_set: true, options: [] }, + { + key: 'recall_budget', + label: 'Recall budget', + kind: 'select', + value: 'mid', + description: '', + placeholder: '', + is_set: true, + options: [ + { value: 'low', label: 'low', description: '' }, + { value: 'mid', label: 'mid', description: '' }, + { value: 'high', label: 'high', description: '' } + ] + } + ] + + return { + name: 'hindsight', + label: 'Hindsight', + fields: fields.map((field, index) => ({ ...field, ...overrides[index] })) + } +} + +beforeEach(() => { + getMemoryProviderConfig.mockResolvedValue(hindsightSchema()) + saveMemoryProviderConfig.mockResolvedValue({ ok: true }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +async function renderPanel(provider = 'hindsight') { + const { ProviderConfigPanel } = await import('./provider-config-panel') + + return render() +} + +describe('ProviderConfigPanel', () => { + it('renders the declared provider fields generically', async () => { + await renderPanel() + + expect(await screen.findByDisplayValue('https://api.hindsight.vectorize.io')).toBeTruthy() + expect(screen.getByDisplayValue('hermes')).toBeTruthy() + expect(screen.getByText('Cloud')).toBeTruthy() + expect(screen.getAllByText('Hindsight Cloud API (lightweight, just needs an API key)').length).toBeGreaterThan(0) + expect(screen.getByText('mid')).toBeTruthy() + }) + + it('collapses and expands the fields', async () => { + await renderPanel() + + expect(await screen.findByLabelText('API URL')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ })) + expect(screen.queryByLabelText('API URL')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ })) + expect(await screen.findByLabelText('API URL')).toBeTruthy() + }) + + it('saves edited values without requiring a secret replacement', async () => { + await renderPanel() + + const apiUrl = await screen.findByLabelText('API URL') + fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } }) + fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => + expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', { + mode: 'cloud', + api_key: '', + api_url: 'http://localhost:8888', + bank_id: 'ben-bank', + recall_budget: 'mid' + }) + ) + }) + + it('renders nothing for a provider with no declared config surface', async () => { + getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', fields: [] }) + + const { container } = await renderPanel('builtin') + + await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin')) + expect(container.querySelector('section')).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/settings/provider-config-panel.tsx b/apps/desktop/src/app/settings/provider-config-panel.tsx new file mode 100644 index 000000000000..d76c0eff2c5f --- /dev/null +++ b/apps/desktop/src/app/settings/provider-config-panel.tsx @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes' +import { Check, Loader2, Save } from '@/lib/icons' +import { notify, notifyError } from '@/store/notifications' +import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes' + +import { CONTROL_TEXT } from './constants' +import { LoadingState, Pill } from './primitives' + +/** Seed editable values from the schema: non-secret fields keep their current + * value, secret fields start blank (their value is never returned). */ +function seedValues(config: MemoryProviderConfig): Record { + return Object.fromEntries( + config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value]) + ) +} + +function FieldControl({ + field, + value, + onChange +}: { + field: MemoryProviderField + value: string + onChange: (value: string) => void +}) { + if (field.kind === 'select') { + const selected = field.options.find(option => option.value === value) + + return ( + <> + + {(selected?.description || field.description) && ( + {selected?.description || field.description} + )} + + ) + } + + if (field.kind === 'secret') { + return ( +
+ onChange(event.target.value)} + placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder} + type="password" + value={value} + /> + {field.is_set && ( + + + Set + + )} +
+ ) + } + + return ( + onChange(event.target.value)} + placeholder={field.placeholder} + value={value} + /> + ) +} + +export function ProviderConfigPanel({ provider }: { provider: string }) { + const [config, setConfig] = useState(null) + const [values, setValues] = useState>({}) + const [expanded, setExpanded] = useState(true) + const [saving, setSaving] = useState(false) + + const refresh = useCallback(async () => { + try { + const next = await getMemoryProviderConfig(provider) + setConfig(next) + setValues(seedValues(next)) + } catch (err) { + notifyError(err, 'Memory provider settings failed to load') + setConfig(null) + } + }, [provider]) + + useEffect(() => { + setConfig(null) + void refresh() + }, [refresh]) + + const save = useCallback(async () => { + if (!config) { + return + } + + setSaving(true) + + try { + await saveMemoryProviderConfig(provider, values) + notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' }) + await refresh() + } catch (err) { + notifyError(err, `Failed to save ${config.label} settings`) + } finally { + setSaving(false) + } + }, [config, provider, refresh, values]) + + // Providers without a declared config surface (e.g. builtin) render nothing. + if (config && config.fields.length === 0) { + return null + } + + if (!config) { + return + } + + const secretFields = config.fields.filter(field => field.kind === 'secret') + + return ( +
+ + + {expanded && ( +
+ {config.fields.map(field => ( + + ))} + +
+ +
+
+ )} +
+ ) +} diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 5d8d70b38a89..3b200a598f4a 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -17,6 +17,7 @@ import type { HermesConfig, HermesConfigRecord, LogsResponse, + MemoryProviderConfig, MessagingPlatformsResponse, MessagingPlatformTestResponse, MessagingPlatformUpdate, @@ -71,6 +72,7 @@ export type { HermesConfig, HermesConfigRecord, LogsResponse, + MemoryProviderConfig, MessagingEnvVarInfo, MessagingHomeChannel, MessagingPlatformInfo, @@ -339,6 +341,23 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool }) } +export function getMemoryProviderConfig(provider: string): Promise { + return window.hermesDesktop.api({ + path: `/api/memory/providers/${encodeURIComponent(provider)}/config` + }) +} + +export function saveMemoryProviderConfig( + provider: string, + values: Record +): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + path: `/api/memory/providers/${encodeURIComponent(provider)}/config`, + method: 'PUT', + body: { values } + }) +} + export function getEnvVars(): Promise> { return window.hermesDesktop.api>({ ...profileScoped(), diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 55019fb0827c..a497e3f10a94 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -113,6 +113,31 @@ export interface EnvVarInfo { url: null | string } +export type MemoryProviderFieldKind = 'secret' | 'select' | 'text' + +export interface MemoryProviderFieldOption { + description: string + label: string + value: string +} + +export interface MemoryProviderField { + description: string + is_set: boolean + key: string + kind: MemoryProviderFieldKind + label: string + options: MemoryProviderFieldOption[] + placeholder: string + value: string +} + +export interface MemoryProviderConfig { + fields: MemoryProviderField[] + label: string + name: string +} + export interface MessagingEnvVarInfo { advanced: boolean description: string diff --git a/hermes_cli/memory_providers.py b/hermes_cli/memory_providers.py new file mode 100644 index 000000000000..9915a75f6a5f --- /dev/null +++ b/hermes_cli/memory_providers.py @@ -0,0 +1,149 @@ +"""Declarative configuration schema for desktop memory providers. + +Each memory provider *declares* its configurable surface here — the fields, their +types, which values are secrets, and (for selects) the allowed options. A single +generic renderer in the desktop UI and a single generic ``GET/PUT +/api/memory/providers/{name}/config`` endpoint pair drive the whole experience, +so adding a new provider (mem0, honcho, ...) is pure declaration with zero +bespoke UI components or endpoints. + +This module is intentionally pure data: it imports nothing from the config/env +layer. ``web_server`` owns the generic read/write logic that interprets these +declarations against config.yaml, the provider config file, and the env store. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field as dataclass_field + +# Field kinds understood by the generic renderer. +KIND_TEXT = "text" +KIND_SELECT = "select" +KIND_SECRET = "secret" + + +@dataclass(frozen=True) +class ProviderFieldOption: + """A single choice for a ``select`` field.""" + + value: str + label: str + description: str = "" + + +@dataclass(frozen=True) +class ProviderField: + """One configurable field on a memory provider. + + A field is stored in exactly one place, decided by ``kind``: + + * ``text`` / ``select`` — persisted to the provider's JSON config file + (``//config.json``) under ``key``. + * ``secret`` — persisted to the env store under ``env_key`` and never read + back out over the API (only an ``is_set`` flag is surfaced). + + ``aliases`` and ``env_fallbacks`` let a field read legacy values written by + earlier CLI/env setup without re-introducing per-provider code. + """ + + key: str + label: str + kind: str = KIND_TEXT + default: str = "" + description: str = "" + placeholder: str = "" + options: tuple[ProviderFieldOption, ...] = () + env_key: str | None = None + aliases: tuple[str, ...] = () + env_fallbacks: tuple[str, ...] = () + + @property + def is_secret(self) -> bool: + return self.kind == KIND_SECRET + + def allowed_values(self) -> set[str]: + return {opt.value for opt in self.options} + + +@dataclass(frozen=True) +class MemoryProvider: + """A declared memory provider and its configurable fields.""" + + name: str + label: str + fields: tuple[ProviderField, ...] = dataclass_field(default_factory=tuple) + + +HINDSIGHT = MemoryProvider( + name="hindsight", + label="Hindsight", + fields=( + ProviderField( + key="mode", + label="Mode", + kind=KIND_SELECT, + default="cloud", + description="How Hermes connects to Hindsight.", + options=( + ProviderFieldOption( + "cloud", + "Cloud", + "Hindsight Cloud API (lightweight, just needs an API key)", + ), + ProviderFieldOption( + "local_external", + "Local External", + "Connect to an existing Hindsight instance", + ), + ), + ), + ProviderField( + key="api_key", + label="API key", + kind=KIND_SECRET, + env_key="HINDSIGHT_API_KEY", + description="Used to authenticate with the Hindsight API.", + placeholder="Enter Hindsight API key", + ), + ProviderField( + key="api_url", + label="API URL", + kind=KIND_TEXT, + default="https://api.hindsight.vectorize.io", + aliases=("apiUrl",), + env_fallbacks=("HINDSIGHT_API_URL",), + ), + ProviderField( + key="bank_id", + label="Bank ID", + kind=KIND_TEXT, + default="hermes", + aliases=("bankId",), + ), + ProviderField( + key="recall_budget", + label="Recall budget", + kind=KIND_SELECT, + default="mid", + aliases=("budget",), + options=( + ProviderFieldOption("low", "low"), + ProviderFieldOption("mid", "mid"), + ProviderFieldOption("high", "high"), + ), + ), + ), +) + + +# Registry of providers that expose a desktop config surface. Providers without +# an entry here (e.g. ``builtin``) simply render no config panel. +MEMORY_PROVIDERS: dict[str, MemoryProvider] = { + HINDSIGHT.name: HINDSIGHT, +} + + +def get_memory_provider(name: str) -> MemoryProvider | None: + """Return the declared provider for ``name``, or ``None`` if undeclared.""" + + return MEMORY_PROVIDERS.get(name) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 9f451ddfd0ce..9a6f28a68b50 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -62,6 +62,11 @@ recommended_update_command_for_method, redact_key, ) +from hermes_cli.memory_providers import ( + MemoryProvider, + ProviderField, + get_memory_provider, +) from gateway.status import ( get_running_pid, get_runtime_status_running_pid, @@ -673,6 +678,10 @@ class EnvVarReveal(BaseModel): profile: Optional[str] = None +class MemoryProviderConfigUpdate(BaseModel): + values: Dict[str, str] = {} + + class MessagingPlatformUpdate(BaseModel): enabled: Optional[bool] = None env: Dict[str, str] = {} @@ -3163,6 +3172,160 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]: return config +def _memory_provider_config_path(provider: MemoryProvider) -> Path: + return get_hermes_home() / provider.name / "config.json" + + +def _read_memory_provider_file(provider: MemoryProvider) -> Dict[str, Any]: + path = _memory_provider_config_path(provider) + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + _log.warning("Failed to read memory provider config from %s", path, exc_info=True) + return {} + return data if isinstance(data, dict) else {} + + +def _read_field_value(field: ProviderField, data: Dict[str, Any]) -> str: + """Resolve the stored value for a non-secret field, honoring legacy reads.""" + + for source_key in (field.key, *field.aliases): + value = data.get(source_key) + if value: + return str(value) + + env_on_disk = load_env() + for env_key in field.env_fallbacks: + value = env_on_disk.get(env_key) + if value: + return str(value) + + return field.default + + +def _field_is_set(field: ProviderField, data: Dict[str, Any]) -> bool: + """Whether a secret field has a value anywhere it may have been written.""" + + env_on_disk = load_env() + for env_key in (field.env_key, *field.env_fallbacks): + if env_key and env_on_disk.get(env_key): + return True + return any(data.get(source_key) for source_key in (field.key, *field.aliases)) + + +def _memory_provider_payload(provider: MemoryProvider) -> Dict[str, Any]: + data = _read_memory_provider_file(provider) + fields: List[Dict[str, Any]] = [] + + for field in provider.fields: + entry: Dict[str, Any] = { + "key": field.key, + "label": field.label, + "kind": field.kind, + "description": field.description, + "placeholder": field.placeholder, + "options": [ + {"value": opt.value, "label": opt.label, "description": opt.description} + for opt in field.options + ], + } + + if field.is_secret: + # Secrets are write-only over the API; only expose whether one is set. + entry["value"] = "" + entry["is_set"] = _field_is_set(field, data) + else: + value = _read_field_value(field, data) + if field.kind == "select" and value not in field.allowed_values(): + value = field.default + entry["value"] = value + entry["is_set"] = bool(value) + + fields.append(entry) + + return {"name": provider.name, "label": provider.label, "fields": fields} + + +def _coerce_field_value(field: ProviderField, raw: str) -> str: + """Validate and normalize a submitted non-secret value, or raise ValueError.""" + + value = (raw or "").strip() + if field.kind == "select": + if not value: + value = field.default + if value not in field.allowed_values(): + raise ValueError(f"Invalid value for '{field.key}'") + return value + return value or field.default + + +@app.get("/api/memory/providers/{name}/config") +async def get_memory_provider_config(name: str): + provider = get_memory_provider(name) + if provider is None: + # Undeclared providers (e.g. builtin) have no config surface. Return an + # empty schema so the generic panel simply renders nothing. + return {"name": name, "label": name, "fields": []} + return _memory_provider_payload(provider) + + +@app.put("/api/memory/providers/{name}/config") +async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate): + provider = get_memory_provider(name) + if provider is None: + raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") + + values = body.values or {} + + try: + existing = _read_memory_provider_file(provider) + json_values: Dict[str, Any] = {} + secrets: Dict[str, str] = {} + + for field in provider.fields: + if field.is_secret: + submitted = (values.get(field.key) or "").strip() + if submitted and field.env_key: + secrets[field.env_key] = submitted + continue + + raw = ( + values[field.key] + if field.key in values + else str(existing.get(field.key, field.default)) + ) + json_values[field.key] = _coerce_field_value(field, raw) + + config = load_config() + memory_config = config.get("memory") + if not isinstance(memory_config, dict): + memory_config = {} + config["memory"] = memory_config + memory_config["provider"] = provider.name + save_config(config) + + path = _memory_provider_config_path(provider) + path.parent.mkdir(parents=True, exist_ok=True) + existing.update(json_values) + from utils import atomic_json_write + + atomic_json_write(path, existing, mode=0o600) + + for env_key, secret in secrets.items(): + save_env_value(env_key, secret) + + return {"ok": True} + except HTTPException: + raise + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception: + _log.exception("PUT /api/memory/providers/%s/config failed", name) + raise HTTPException(status_code=500, detail="Internal server error") + + @app.get("/api/config") async def get_config(profile: Optional[str] = None): with _profile_scope(profile): diff --git a/tests/hermes_cli/test_memory_providers.py b/tests/hermes_cli/test_memory_providers.py new file mode 100644 index 000000000000..9130bdaea7d8 --- /dev/null +++ b/tests/hermes_cli/test_memory_providers.py @@ -0,0 +1,46 @@ +"""Tests for the declarative memory-provider registry.""" + +from hermes_cli.memory_providers import ( + KIND_SECRET, + KIND_SELECT, + get_memory_provider, +) + + +def test_hindsight_is_declared(): + provider = get_memory_provider("hindsight") + + assert provider is not None + assert provider.label == "Hindsight" + assert {field.key for field in provider.fields} == { + "mode", + "api_key", + "api_url", + "bank_id", + "recall_budget", + } + + +def test_hindsight_mode_gating_is_expressed_as_select_options(): + provider = get_memory_provider("hindsight") + assert provider is not None + + mode = next(field for field in provider.fields if field.key == "mode") + assert mode.kind == KIND_SELECT + assert mode.allowed_values() == {"cloud", "local_external"} + # local_embedded is intentionally unsupported on desktop. + assert "local_embedded" not in mode.allowed_values() + + +def test_api_key_is_a_secret_bound_to_env(): + provider = get_memory_provider("hindsight") + assert provider is not None + + api_key = next(field for field in provider.fields if field.key == "api_key") + assert api_key.kind == KIND_SECRET + assert api_key.is_secret is True + assert api_key.env_key == "HINDSIGHT_API_KEY" + + +def test_unknown_provider_is_none(): + assert get_memory_provider("builtin") is None diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 4312cc08152d..f03265ee6788 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -264,6 +264,110 @@ def test_dashboard_update_capability_detects_generic_container(self, monkeypatch assert web_server._dashboard_local_update_managed_externally() is True + @staticmethod + def _provider_field_map(payload): + return {field["key"]: field for field in payload["fields"]} + + def test_get_memory_provider_config_returns_safe_defaults(self): + resp = self.client.get("/api/memory/providers/hindsight/config") + + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "hindsight" + assert data["label"] == "Hindsight" + + fields = self._provider_field_map(data) + assert fields["mode"]["kind"] == "select" + assert fields["mode"]["value"] == "cloud" + assert {opt["value"] for opt in fields["mode"]["options"]} == {"cloud", "local_external"} + assert fields["api_url"]["value"] == "https://api.hindsight.vectorize.io" + assert fields["bank_id"]["value"] == "hermes" + assert fields["recall_budget"]["value"] == "mid" + assert fields["api_key"]["kind"] == "secret" + assert fields["api_key"]["is_set"] is False + + def test_put_memory_provider_config_writes_config_and_secret(self): + from hermes_constants import get_hermes_home + from hermes_cli.config import load_config, load_env + + resp = self.client.put( + "/api/memory/providers/hindsight/config", + json={ + "values": { + "mode": "local_external", + "api_url": "http://localhost:8888", + "api_key": "hs-test-key", + "bank_id": "ben-bank", + "recall_budget": "high", + } + }, + ) + + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + assert load_config()["memory"]["provider"] == "hindsight" + assert load_env()["HINDSIGHT_API_KEY"] == "hs-test-key" + + config_path = get_hermes_home() / "hindsight" / "config.json" + provider_config = json.loads(config_path.read_text(encoding="utf-8")) + assert provider_config == { + "mode": "local_external", + "api_url": "http://localhost:8888", + "bank_id": "ben-bank", + "recall_budget": "high", + } + + def test_put_memory_provider_config_rejects_unsupported_select_value(self): + resp = self.client.put( + "/api/memory/providers/hindsight/config", + json={ + "values": { + "mode": "local_embedded", + "api_url": "http://localhost:8888", + "bank_id": "hermes", + "recall_budget": "mid", + } + }, + ) + + assert resp.status_code == 400 + + def test_put_unknown_memory_provider_returns_404(self): + resp = self.client.put( + "/api/memory/providers/nope/config", json={"values": {}} + ) + + assert resp.status_code == 404 + + def test_get_unknown_memory_provider_returns_empty_schema(self): + resp = self.client.get("/api/memory/providers/builtin/config") + + assert resp.status_code == 200 + assert resp.json()["fields"] == [] + + def test_get_memory_provider_config_does_not_return_secret(self): + self.client.put( + "/api/memory/providers/hindsight/config", + json={ + "values": { + "mode": "cloud", + "api_url": "https://api.hindsight.vectorize.io", + "api_key": "secret-value", + "bank_id": "hermes", + "recall_budget": "mid", + } + }, + ) + + resp = self.client.get("/api/memory/providers/hindsight/config") + + assert resp.status_code == 200 + data = resp.json() + fields = self._provider_field_map(data) + assert fields["api_key"]["is_set"] is True + assert fields["api_key"]["value"] == "" + assert "secret-value" not in json.dumps(data) + # ── GET /api/media (remote image display) ─────────────────────────── def test_get_media_serves_image_in_root(self): @@ -377,7 +481,6 @@ def test_dashboard_font_override_independent_of_theme(self): assert config["dashboard"]["theme"] == "ember" assert config["dashboard"]["font"] == "jetbrains-mono" - def test_get_sessions_uses_only_persisted_cwd(self, monkeypatch): """Session rows without persisted cwd must not inherit TERMINAL_CWD. From d2c53ff5583eca0e5f4009a3fcc28c5da8b17fce Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Fri, 19 Jun 2026 09:33:15 +1000 Subject: [PATCH 15/28] feat(relay): WS-only inbound on the gateway adapter (Phase 3) (#48294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connector now delivers inbound (messages + interrupts) over the gateway's OUTBOUND /relay WebSocket, not a signed HTTP POST to an inbound endpoint. The gateway needs no inbound HTTP port — which is what makes hosted gateways (no public IP) able to receive inbound at all. - gateway/relay/adapter.py: connect() wires set_interrupt_inbound_handler( self.on_interrupt) so connector->gateway interrupt_inbound frames bridge into the existing per-session interrupt path (the inbound message handler was already wired). Removed _maybe_start_inbound_receiver() + the _inbound_runner lifecycle — there is no HTTP receiver anymore. - gateway/relay/inbound_receiver.py: deleted (the signed-HTTP InboundDelivery receiver). - gateway/relay/__init__.py: removed relay_inbound_config() (dead with the receiver gone). The delivery key is still set in-process by self-provision for forward-compat but is no longer consumed for inbound. - docs/relay-connector-contract.md: §3 rewritten — inbound is the WS back-channel routed cross-instance via the connector's relay bus; §5 interrupt + §6 auth table updated; the old signed-HTTP-POST + per-tenant-delivery-key-signing path is documented as superseded. gatewayEndpoint noted as passthrough-plane only. Tests: stub_connector grows set_interrupt_inbound_handler + push_interrupt; new test_relay_interrupt case proves connect() wires BOTH inbound handlers and an interrupt_inbound frame over the WS cancels the right session. Removed the HTTP-receiver test; updated the crypto-shedding scan + self-provision delivery-key assertion. 88 relay tests pass. EXPERIMENTAL. Pairs with gateway-gateway (relay bus + WsGatewayDelivery) and the NAS GATEWAY_RELAY_URL stamp. The cross-repo E2E (connector repo) proves the full multi-instance path against this production adapter code. --- docs/relay-connector-contract.md | 93 +++++--- gateway/relay/__init__.py | 41 +--- gateway/relay/adapter.py | 52 +---- gateway/relay/inbound_receiver.py | 204 ------------------ tests/gateway/relay/stub_connector.py | 12 ++ tests/gateway/relay/test_inbound_receiver.py | 150 ------------- tests/gateway/relay/test_relay_interrupt.py | 20 ++ .../gateway/relay/test_relay_sheds_crypto.py | 18 +- tests/gateway/relay/test_self_provision.py | 7 +- 9 files changed, 119 insertions(+), 478 deletions(-) delete mode 100644 gateway/relay/inbound_receiver.py delete mode 100644 tests/gateway/relay/test_inbound_receiver.py diff --git a/docs/relay-connector-contract.md b/docs/relay-connector-contract.md index 39c86a5f8393..54fff9406cc4 100644 --- a/docs/relay-connector-contract.md +++ b/docs/relay-connector-contract.md @@ -62,33 +62,55 @@ live platform adapter's capability methods. The connector normalizes each platform wire event into a `MessageEvent` (`gateway/platforms/base.py`) and delivers it to the gateway. **Inbound is -delivered over a signed HTTP POST, not the outbound `/relay` WebSocket** (see -the transport note below). The gateway keys the session via `build_session_key()` +delivered over the gateway's OUTBOUND `/relay` WebSocket** (see the transport +note below) — the connector pushes an `inbound` frame down the socket the +gateway already dialed. The gateway keys the session via `build_session_key()` from the embedded `SessionSource` — so populating the right discriminators is the single highest-correctness responsibility of the connector. -### Inbound transport (signed HTTP POST, not the outbound WS) +### Inbound transport (WS back-channel, not HTTP) The gateway dials **out** to the connector's `/relay` WebSocket for the -handshake + outbound actions (§4) + its own `/stop` egress (§5). Inbound, -however, is delivered the other way: the connector **POSTs** the normalized -event to the gateway's inbound endpoint (`HttpGatewayDelivery` on the connector; -`gateway/relay/inbound_receiver.py` on the gateway). The reason is -multi-instance: the connector instance that owns a platform's socket (and thus -produces inbound events) is generally **not** the instance a given gateway -dialed its outbound WS into, so inbound must target a tenant **endpoint** (which -may load-balance across gateway instances) rather than ride one gateway's -outbound socket. Each delivery is HMAC-signed with the per-tenant **delivery -key** (§6.1); the gateway verifies the signature over the exact raw bytes before -accepting the event. Two POST targets: - -- `POST {gatewayEndpoint}` → `{"type":"message", "event": }` -- `POST {gatewayEndpoint}/interrupt` → `{"type":"interrupt", "session_key", "reason"?}` (§5) - -> An earlier draft of this contract delivered inbound over the WS `inbound` -> frame. That only works single-instance and predates the multi-instance -> socket-ownership + channel-auth model; the signed-HTTP path above is the -> shipped design. +handshake + outbound actions (§4) + its own `/stop` egress (§5). Inbound rides +the **same socket** in the other direction: the connector pushes an `inbound` +frame (and `interrupt_inbound` for §5) down the gateway's outbound WS. There is +**no gateway-side inbound HTTP endpoint** — a gateway need not (and, when hosted, +cannot) expose any inbound port; everything flows over the connection it +initiated. + +**Multi-instance routing.** The connector instance that owns a platform's socket +(and thus produces inbound events) is generally **not** the instance the gateway +dialed its outbound WS into. The producing instance therefore publishes the +event on the connector's internal **relay bus** (Redis pub/sub; `RelayBus` in +`src/core/relayBus.ts`) keyed by tenant. Every connector instance subscribes and +routes each message to its **local** sessions for that tenant +(`RelayServer.routeBusMessage`); the single instance that actually holds the +gateway's socket delivers it, and instances with no local session for the tenant +no-op. Cross-instance delivery is thus an in-cluster Redis hop, not a public +HTTP call. + +Frames (connector → gateway, over the WS): + +- `{"type":"inbound", "event": , "bufferId"?}` +- `{"type":"interrupt_inbound", "session_key", "chat_id"}` (§5) + +**Trust.** The WS upgrade is authenticated with the gateway's per-gateway secret +(§6.1), so the channel is trusted end to end — inbound frames are not separately +HMAC-signed (the authenticated socket subsumes the per-delivery origin proof the +old HTTP path needed). The relay-bus hop is inside the connector trust domain +(same as the lease/buffer/capability stores). + +> Earlier drafts of this contract delivered inbound over a signed **HTTP POST** +> to a `gatewayEndpoint` (`HttpGatewayDelivery` + a gateway-side +> `inbound_receiver`), HMAC-signed with a per-tenant delivery key. That required +> every gateway to expose a reachable inbound URL — impossible for hosted +> gateways, which have no public IP. The WS back-channel above replaces it; the +> per-tenant delivery key is retained at provision for forward-compat but is no +> longer used for inbound. `gatewayEndpoint` remains only for the **passthrough +> plane** (Class-2/3 webhooks like Discord interactions / Twilio), which is a +> separate synchronous-forward path and out of scope for this section. + + ### SessionSource fields (the wire surface) @@ -178,13 +200,15 @@ gateway holds zero capability material). Source of truth: mid-turn `/stop` over the outbound WS. The connector MUST forward it to the gateway instance running that `session_key` (the routing invariant). - **Connector → gateway:** an inbound interrupt for a `session_key` is delivered - as a **signed HTTP POST** to `{gatewayEndpoint}/interrupt` (§3 transport note), - and bridged by the adapter's `on_interrupt(session_key, chat_id)` into the - existing per-session interrupt mechanism, cancelling exactly that turn + as an `interrupt_inbound` frame down the gateway's outbound WS (§3 transport + note) — routed cross-instance via the relay bus to whichever instance holds + the socket — and bridged by the adapter's `on_interrupt(session_key, chat_id)` + into the existing per-session interrupt mechanism, cancelling exactly that turn (siblings untouched). -The gateway→connector `/stop` rides the outbound WS; the connector→gateway -interrupt rides the same signed-HTTP inbound path as a normalized event. +Both directions ride the gateway's outbound WS: the gateway→connector `/stop` +egresses over it, and the connector→gateway interrupt rides the same `inbound` +back-channel as a normalized event. --- @@ -231,20 +255,21 @@ only in transport. See `docs/capability-trust-boundary.md` (connector repo: A2 makes the connector the sole holder of platform secrets while the gateway may be **customer-managed and internet-exposed**, so the connector⇄gateway channel -is itself authenticated. The gateway holds two enrollment-issued credentials -(`hermes gateway enroll` → connector `/relay/enroll`): a **per-gateway secret** -and a **per-tenant delivery key**. Both are HMAC-SHA256 schemes with a -multi-secret rotation verify list (gateway side: `gateway/relay/auth.py`; -connector side: `src/core/relayAuthToken.ts` + `src/core/deliverySigning.ts`). +is itself authenticated. The gateway holds an enrollment- or provision-issued +**per-gateway secret** (`hermes gateway enroll` → connector `/relay/enroll`, or +managed self-provision → `/relay/provision`) that authenticates its outbound WS +upgrade. It is an HMAC-SHA256 scheme with a multi-secret rotation verify list +(gateway side: `gateway/relay/auth.py`; connector side: +`src/core/relayAuthToken.ts`). | Leg | Credential | Mechanism | |-----|-----------|-----------| | Gateway → connector WS upgrade | per-gateway secret | An `Authorization` bearer header on the `/relay` upgrade. The token is `base64url(payload:exp:sig)` where `payload = gatewayId` and `sig = HMAC(payload:exp, secret)`. Connector verifies and rejects the upgrade (**close 4401**) on mismatch/absence/revocation. The authenticated tenant comes from the connector's store, never the `hello` frame. | -| Connector → gateway inbound POST | per-tenant delivery key | Two headers: `x-relay-timestamp` (unix seconds) and `x-relay-signature` (hex `HMAC(ts.rawBody, deliveryKey)`). Gateway verifies over the **exact raw bytes** within a ±300s replay window before accepting the event; rejects **401** otherwise. | +| Connector → gateway inbound (`inbound` / `interrupt_inbound` frames) | — (rides the authenticated WS) | Inbound is pushed down the gateway's already-authenticated outbound socket (§3), so no per-message signature is needed. A **per-tenant delivery key** is still issued at enroll/provision and retained for forward-compat, but is no longer used to sign inbound. | This is the **channel** authenticator — distinct from platform crypto, which the relay path still sheds entirely (§6). The gateway holds zero platform secrets; -these two keys authenticate only the connector link. Full threat model + +the per-gateway secret authenticates only the connector link. Full threat model + enrollment/rotation/kill-switch design: `docs/connector-gateway-auth-design.md` (connector repo). diff --git a/gateway/relay/__init__.py b/gateway/relay/__init__.py index 421fe0ac240c..a0bd4f526eff 100644 --- a/gateway/relay/__init__.py +++ b/gateway/relay/__init__.py @@ -79,40 +79,6 @@ def relay_connection_auth() -> tuple[Optional[str], Optional[str]]: return (gateway_id or None, secret or None) -def relay_inbound_config() -> tuple[Optional[str], Optional[str], int]: - """Resolve (delivery_key, bind_host, bind_port) for the inbound receiver. - - The connector delivers normalized inbound events to this gateway over a - SIGNED HTTP POST (not the outbound WS), verified with the per-tenant delivery - key issued at enrollment (``GATEWAY_RELAY_DELIVERY_KEY``). The receiver only - starts when a delivery key AND a bind port are configured — a gateway with no - public inbound URL (e.g. a purely outbound dev run) simply doesn't run it. - - Env first (Docker), then ``gateway.relay_delivery_key`` / - ``gateway.relay_inbound_host`` / ``gateway.relay_inbound_port`` in config.yaml. - Port 0 (default/unset) -> receiver disabled. - """ - key = os.environ.get("GATEWAY_RELAY_DELIVERY_KEY", "").strip() - host = os.environ.get("GATEWAY_RELAY_INBOUND_HOST", "").strip() - port_raw = os.environ.get("GATEWAY_RELAY_INBOUND_PORT", "").strip() - if not (key and port_raw): - try: - from gateway.run import _load_gateway_config # late import to avoid cycle - - cfg = (_load_gateway_config().get("gateway") or {}) - key = key or str(cfg.get("relay_delivery_key", "") or "").strip() - host = host or str(cfg.get("relay_inbound_host", "") or "").strip() - if not port_raw: - port_raw = str(cfg.get("relay_inbound_port", "") or "").strip() - except Exception: # noqa: BLE001 - config absence/parse must never crash registration - pass - try: - port = int(port_raw) if port_raw else 0 - except ValueError: - port = 0 - return (key or None, host or "0.0.0.0", port) - - def relay_endpoint() -> Optional[str]: """The gateway's own PUBLIC inbound URL, asserted to the connector at provision. @@ -318,8 +284,11 @@ def self_provision_if_managed() -> bool: logger.warning("relay self-provision failed (%s); gateway will boot without relay auth", exc) return False - # Set creds in-process so register_relay_adapter() + relay_inbound_config() - # read them from os.environ. Never logged. + # Set creds in-process so register_relay_adapter() reads them from os.environ + # (the per-gateway secret authenticates the outbound WS upgrade). The delivery + # key is still issued by the connector and persisted for forward-compat, but + # inbound now rides the WS (no HTTP receiver), so it is not consumed here. + # Never logged. os.environ["GATEWAY_RELAY_ID"] = str(result.get("gatewayId") or gateway_id) os.environ["GATEWAY_RELAY_SECRET"] = str(result.get("secret") or "") os.environ["GATEWAY_RELAY_DELIVERY_KEY"] = str(result.get("deliveryKey") or "") diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index b64f7abc517a..fc4e5f40ee7a 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -58,10 +58,6 @@ def __init__( # Capability surface read by stream_consumer (getattr(..., 4096)). self.MAX_MESSAGE_LENGTH = descriptor.max_message_length self.supports_code_blocks = descriptor.markdown_dialect not in ("", "plain") - # Inbound delivery receiver (signed connector→gateway HTTP POSTs). Built - # lazily in connect() when a delivery key + bind port are configured; a - # purely-outbound dev gateway runs without it. See inbound_receiver.py. - self._inbound_runner: Any = None # ── capability surface (from descriptor) ───────────────────────────── @property @@ -80,6 +76,12 @@ async def connect(self) -> bool: if self._transport is None: raise RuntimeError("RelayAdapter has no transport configured") self._transport.set_inbound_handler(self._on_inbound) + # Inbound interrupts (connector -> owning gateway) arrive as + # interrupt_inbound frames over the SAME outbound WS; bridge them to the + # adapter's interrupt path. WS-only: there is no inbound HTTP receiver. + set_interrupt = getattr(self._transport, "set_interrupt_inbound_handler", None) + if callable(set_interrupt): + set_interrupt(self.on_interrupt) ok = await self._transport.connect() if not ok: return False @@ -92,40 +94,12 @@ async def connect(self) -> bool: logger.warning("relay handshake failed: %s", exc) return False self._apply_descriptor(descriptor) - # Start the signed inbound-delivery receiver if configured (the connector - # POSTs normalized events to it over HTTP, verified with the tenant - # delivery key). Non-fatal: a receiver bind failure must not fail the - # outbound connection — the gateway can still send. - await self._maybe_start_inbound_receiver() + # Inbound (messages + interrupts) is delivered over the outbound WS via + # the connector's relay bus — there is NO inbound HTTP endpoint (hosted + # gateways have no public IP). The transport's reader already dispatches + # `inbound` / `interrupt_inbound` frames to the handlers wired above. return True - async def _maybe_start_inbound_receiver(self) -> None: - """Start the inbound HTTP receiver when a delivery key + port are set.""" - from gateway.relay import relay_inbound_config - - delivery_key, host, port = relay_inbound_config() - if not (delivery_key and port): - return # no inbound URL configured -> outbound-only gateway - try: - from aiohttp import web - - from gateway.relay.inbound_receiver import InboundDeliveryReceiver - - receiver = InboundDeliveryReceiver( - delivery_key_verify_list=lambda: [delivery_key], - on_message=self._on_inbound, - on_interrupt=self.on_interrupt, - ) - runner = web.AppRunner(receiver.build_app(), access_log=None) - await runner.setup() - site = web.TCPSite(runner, host, port) - await site.start() - self._inbound_runner = runner - logger.info("relay inbound receiver listening on http://%s:%s", host, port) - except Exception as exc: # noqa: BLE001 - inbound bind failure must not kill outbound - logger.warning("relay inbound receiver failed to start: %s", exc) - self._inbound_runner = None - def _apply_descriptor(self, descriptor: CapabilityDescriptor) -> None: """Adopt a (re)negotiated descriptor into the live capability surface.""" self.descriptor = descriptor @@ -148,12 +122,6 @@ async def on_interrupt(self, session_key: str, chat_id: str) -> None: await self.interrupt_session_activity(session_key, chat_id) async def disconnect(self) -> None: - if self._inbound_runner is not None: - try: - await self._inbound_runner.cleanup() - except Exception: # noqa: BLE001 - best-effort teardown - pass - self._inbound_runner = None if self._transport is not None: await self._transport.disconnect() diff --git a/gateway/relay/inbound_receiver.py b/gateway/relay/inbound_receiver.py deleted file mode 100644 index 733fe38c2c6b..000000000000 --- a/gateway/relay/inbound_receiver.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Gateway-side inbound delivery receiver. EXPERIMENTAL. - -The connector delivers normalized inbound events to a tenant's gateway over a -**signed HTTP POST** (connector ``src/relay/httpGatewayDelivery.ts``), NOT over -the gateway's outbound ``/relay`` WebSocket: the connector instance that owns a -platform socket is generally not the instance a given gateway dialed out to, so -inbound is delivered to a tenant ENDPOINT (which may load-balance across gateway -instances). Each delivery is HMAC-signed with the per-tenant **delivery key** -(``gateway/relay/auth.py``); this receiver verifies the signature over the EXACT -raw request bytes before accepting the event. - -Two routes (mirroring the connector's two POST targets): - POST {base} {"type":"message", "event": , ...} - POST {base}/interrupt {"type":"interrupt","session_key": ..., "reason"?} - -The receiver: - 1. reads the RAW body bytes (never a reparsed/re-serialized form — the HMAC is - over the literal bytes the connector signed), - 2. verifies ``x-relay-signature`` / ``x-relay-timestamp`` against the delivery - key verify list (primary + secondary during rotation), within the replay - window — rejects 401 on any failure, - 3. parses the JSON and dispatches: a ``message`` to the inbound handler (the - RelayAdapter's ``handle_message`` via the transport's normal path), an - ``interrupt`` to the interrupt handler. - -EXPERIMENTAL: the transport protocol may change without a deprecation cycle -until ≥2 Class-1 platforms validate it. See docs/relay-connector-contract.md. -""" - -from __future__ import annotations - -import json -import logging -from typing import Any, Awaitable, Callable, Optional, Sequence - -from gateway.platforms.base import MessageEvent -from gateway.relay.auth import ( - DELIVERY_SIG_HEADER, - DELIVERY_TS_HEADER, - verify_delivery_signature, -) - -logger = logging.getLogger(__name__) - -# Callbacks the receiver dispatches verified deliveries to. -InboundMessageHandler = Callable[[MessageEvent], Awaitable[None]] -InboundInterruptHandler = Callable[[str, str], Awaitable[None]] - -try: # lazy/optional dep — mirrors the other HTTP-receiving adapters - from aiohttp import web -except ImportError: # pragma: no cover - exercised only when the extra is absent - web = None # type: ignore[assignment] - -AIOHTTP_AVAILABLE = web is not None - - -def _event_from_wire(raw: dict) -> MessageEvent: - """Rebuild a MessageEvent from the connector's normalized inbound payload. - - Identical mapping to the WS transport's ``_event_from_wire`` (the wire shape - is the same; only the transport differs). Kept here so the HTTP receiver has - no import dependency on the WS transport module. - """ - from gateway.config import Platform - from gateway.platforms.base import MessageType - from gateway.session import SessionSource - - src = raw.get("source", {}) or {} - platform = src.get("platform", "relay") - try: - platform_enum = Platform(platform) - except ValueError: - platform_enum = Platform.RELAY - - source = SessionSource( - platform=platform_enum, - chat_id=src.get("chat_id", ""), - chat_type=src.get("chat_type", "dm"), - chat_name=src.get("chat_name"), - user_id=src.get("user_id"), - user_name=src.get("user_name"), - thread_id=src.get("thread_id"), - chat_topic=src.get("chat_topic"), - user_id_alt=src.get("user_id_alt"), - chat_id_alt=src.get("chat_id_alt"), - guild_id=src.get("guild_id"), - parent_chat_id=src.get("parent_chat_id"), - message_id=src.get("message_id"), - ) - try: - msg_type = MessageType(raw.get("message_type", "text")) - except ValueError: - msg_type = MessageType.TEXT - - return MessageEvent( - text=raw.get("text", ""), - message_type=msg_type, - source=source, - message_id=raw.get("message_id"), - reply_to_message_id=raw.get("reply_to_message_id"), - media_urls=raw.get("media_urls") or [], - ) - - -class InboundDeliveryReceiver: - """Verifies + dispatches signed connector→gateway inbound deliveries. - - Transport-agnostic core: ``handle_raw`` takes the raw body bytes + headers + - which route was hit and returns ``(status, body)``. The aiohttp wiring - (``build_app`` / ``serve``) is a thin shell so the verify+dispatch logic is - unit-testable without a live socket. - """ - - def __init__( - self, - *, - delivery_key_verify_list: Callable[[], Sequence[str]], - on_message: InboundMessageHandler, - on_interrupt: Optional[InboundInterruptHandler] = None, - max_skew_seconds: int = 300, - ) -> None: - # A callable (not a static list) so a rotated delivery key is picked up - # without rebuilding the receiver — mirrors the connector's verify list. - self._verify_list = delivery_key_verify_list - self._on_message = on_message - self._on_interrupt = on_interrupt - self._max_skew_seconds = max_skew_seconds - - async def handle_raw( - self, *, raw_body: bytes, timestamp: Optional[str], signature: Optional[str], is_interrupt: bool - ) -> tuple[int, dict]: - """Verify the signature over ``raw_body`` and dispatch. Returns (status, json). - - 401 on a missing/invalid/expired signature (never dispatches unverified). - 400 on malformed JSON. 200 on a verified, dispatched delivery. - """ - verify_keys = list(self._verify_list() or []) - if not verify_keys: - # No delivery key provisioned -> we cannot verify -> reject. A gateway - # that hasn't enrolled must not accept inbound (fail closed). - logger.warning("relay inbound: no delivery key configured; rejecting") - return 401, {"error": "no delivery key configured"} - - # Verify over the EXACT raw bytes the connector signed. Decode to text - # with the same UTF-8 the connector's JSON.stringify produced; a single - # differing byte breaks the HMAC (raw-body-preservation discipline). - body_text = raw_body.decode("utf-8", errors="strict") - if not verify_delivery_signature( - body_text, timestamp, signature, verify_keys, self._max_skew_seconds - ): - return 401, {"error": "invalid delivery signature"} - - try: - payload = json.loads(body_text) - except json.JSONDecodeError: - return 400, {"error": "invalid JSON body"} - - if is_interrupt or payload.get("type") == "interrupt": - session_key = str(payload.get("session_key", "")) - chat_id = str(payload.get("chat_id", "") or payload.get("reason", "") or "") - if self._on_interrupt is not None and session_key: - await self._on_interrupt(session_key, chat_id) - return 200, {"ok": True} - - # Default: a normalized inbound message event. - event_raw = payload.get("event") - if not isinstance(event_raw, dict): - return 400, {"error": "missing event"} - event = _event_from_wire(event_raw) - await self._on_message(event) - return 200, {"ok": True} - - # ── aiohttp wiring (thin shell over handle_raw) ────────────────────── - def build_app(self) -> Any: - """Build an aiohttp Application exposing the delivery + interrupt routes.""" - if not AIOHTTP_AVAILABLE: - raise RuntimeError( - "InboundDeliveryReceiver requires the 'aiohttp' package " - "(install the messaging extra)." - ) - - async def _deliver(request: Any) -> Any: - return await self._respond(request, is_interrupt=False) - - async def _interrupt(request: Any) -> Any: - return await self._respond(request, is_interrupt=True) - - app = web.Application() - app.router.add_get("/healthz", lambda _: web.Response(text="ok")) - app.router.add_post("/", _deliver) - app.router.add_post("/interrupt", _interrupt) - return app - - async def _respond(self, request: Any, *, is_interrupt: bool) -> Any: - # Read the RAW bytes — do NOT use request.json() (it reparses and we'd - # verify over a re-serialized form, breaking the HMAC). - raw_body = await request.read() - status, body = await self.handle_raw( - raw_body=raw_body, - timestamp=request.headers.get(DELIVERY_TS_HEADER), - signature=request.headers.get(DELIVERY_SIG_HEADER), - is_interrupt=is_interrupt, - ) - return web.json_response(body, status=status) diff --git a/tests/gateway/relay/stub_connector.py b/tests/gateway/relay/stub_connector.py index 60e79a81a1bb..11a97cae53a5 100644 --- a/tests/gateway/relay/stub_connector.py +++ b/tests/gateway/relay/stub_connector.py @@ -26,6 +26,7 @@ class StubConnector: def __init__(self, descriptor: CapabilityDescriptor) -> None: self._descriptor = descriptor self._inbound: Optional[InboundHandler] = None + self._interrupt_inbound: Optional[Any] = None self.connected = False self.sent: List[Dict[str, Any]] = [] self.interrupts: List[Dict[str, Any]] = [] @@ -51,6 +52,11 @@ async def handshake(self) -> CapabilityDescriptor: def set_inbound_handler(self, handler: InboundHandler) -> None: self._inbound = handler + def set_interrupt_inbound_handler(self, handler: Any) -> None: + """Mirror the real WS transport: the adapter registers its interrupt + bridge here so connector→gateway interrupt_inbound frames route to it.""" + self._interrupt_inbound = handler + async def send_outbound(self, action: Dict[str, Any]) -> Dict[str, Any]: self.sent.append(action) if action.get("op") == "send": @@ -73,3 +79,9 @@ async def push_inbound(self, event: MessageEvent) -> None: if self._inbound is None: raise RuntimeError("no inbound handler registered (call adapter.connect first)") await self._inbound(event) + + async def push_interrupt(self, session_key: str, chat_id: str) -> None: + """Simulate the connector delivering an interrupt_inbound over the WS.""" + if self._interrupt_inbound is None: + raise RuntimeError("no interrupt_inbound handler registered (call adapter.connect first)") + await self._interrupt_inbound(session_key, chat_id) diff --git a/tests/gateway/relay/test_inbound_receiver.py b/tests/gateway/relay/test_inbound_receiver.py deleted file mode 100644 index 076fc3c95283..000000000000 --- a/tests/gateway/relay/test_inbound_receiver.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Unit tests for gateway/relay/inbound_receiver.py. - -Covers the verify-then-dispatch core (handle_raw): a correctly-signed message -delivery is verified + dispatched; an interrupt delivery routes to the interrupt -handler; unsigned/tampered/expired/no-key deliveries are rejected 401; malformed -JSON is 400. Signatures are produced with the SAME auth primitives the connector -uses (gateway/relay/auth.py sign), so this exercises the real verify path. -""" - -from __future__ import annotations - -import json -import time - -import pytest - -from gateway.relay.auth import sign -from gateway.relay.inbound_receiver import InboundDeliveryReceiver - -_KEY = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" - - -def _signed(body_obj: dict, key: str = _KEY, ts: int | None = None) -> tuple[bytes, str, str]: - """Serialize compactly (as the connector's JSON.stringify does), sign it.""" - body = json.dumps(body_obj, separators=(",", ":")) - raw = body.encode("utf-8") - t = ts if ts is not None else int(time.time()) - return raw, str(t), sign(f"{t}.{body}", key) - - -def _receiver(**kw): - received: list = [] - interrupts: list = [] - - async def on_message(ev): - received.append(ev) - - async def on_interrupt(sk, chat): - interrupts.append((sk, chat)) - - r = InboundDeliveryReceiver( - delivery_key_verify_list=lambda: [_KEY], - on_message=on_message, - on_interrupt=on_interrupt, - **kw, - ) - return r, received, interrupts - - -@pytest.mark.asyncio -async def test_valid_message_delivery_dispatched(): - r, received, _ = _receiver() - raw, ts, sig = _signed( - { - "type": "message", - "event": { - "text": "hello", - "message_type": "text", - "source": {"platform": "discord", "chat_id": "chan1", "chat_type": "group", "guild_id": "guildA"}, - }, - } - ) - status, body = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False) - assert status == 200 and body == {"ok": True} - assert len(received) == 1 - assert received[0].text == "hello" - assert received[0].source.guild_id == "guildA" - - -@pytest.mark.asyncio -async def test_valid_interrupt_delivery_routes_to_interrupt_handler(): - r, _, interrupts = _receiver() - raw, ts, sig = _signed({"type": "interrupt", "session_key": "agent:main:discord:group:c:u", "reason": "stop"}) - status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=True) - assert status == 200 - assert interrupts and interrupts[0][0] == "agent:main:discord:group:c:u" - - -@pytest.mark.asyncio -async def test_tampered_body_rejected_401(): - r, received, _ = _receiver() - raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}) - status, _ = await r.handle_raw(raw_body=raw + b" ", timestamp=ts, signature=sig, is_interrupt=False) - assert status == 401 - assert received == [] - - -@pytest.mark.asyncio -async def test_unsigned_rejected_401(): - r, _, _ = _receiver() - raw, _, _ = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}) - status, _ = await r.handle_raw(raw_body=raw, timestamp=None, signature=None, is_interrupt=False) - assert status == 401 - - -@pytest.mark.asyncio -async def test_expired_timestamp_rejected_401(): - r, _, _ = _receiver(max_skew_seconds=300) - raw, _, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}, ts=1) - # ts=1 (1970) is far outside the 300s window vs now. - status, _ = await r.handle_raw(raw_body=raw, timestamp="1", signature=sig, is_interrupt=False) - assert status == 401 - - -@pytest.mark.asyncio -async def test_wrong_key_rejected_401(): - r, _, _ = _receiver() - other = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100" - raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}, key=other) - status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False) - assert status == 401 - - -@pytest.mark.asyncio -async def test_no_delivery_key_fails_closed_401(): - async def on_message(ev): - pass - - r = InboundDeliveryReceiver(delivery_key_verify_list=lambda: [], on_message=on_message) - raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}) - status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False) - assert status == 401 - - -@pytest.mark.asyncio -async def test_rotation_secondary_key_accepted(): - new = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - received: list = [] - - async def on_message(ev): - received.append(ev) - - # Connector still signs with the OLD key (secondary); verify list has both. - r = InboundDeliveryReceiver( - delivery_key_verify_list=lambda: [new, _KEY], on_message=on_message - ) - raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}, key=_KEY) - status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False) - assert status == 200 and len(received) == 1 - - -@pytest.mark.asyncio -async def test_malformed_json_after_valid_signature_is_400(): - r, _, _ = _receiver() - # Sign a non-JSON body so the signature passes but json.loads fails. - raw = b"not json at all" - ts = str(int(time.time())) - sig = sign(f"{ts}.{raw.decode()}", _KEY) - status, body = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False) - assert status == 400 diff --git a/tests/gateway/relay/test_relay_interrupt.py b/tests/gateway/relay/test_relay_interrupt.py index 49b6d8607ed4..10f34308cf81 100644 --- a/tests/gateway/relay/test_relay_interrupt.py +++ b/tests/gateway/relay/test_relay_interrupt.py @@ -67,3 +67,23 @@ async def test_outbound_interrupt_reaches_connector(adapter): assert stub.interrupts == [ {"session_key": "agent:main:discord:group:chanA:userX", "reason": "stop"} ] + + +@pytest.mark.asyncio +async def test_connect_wires_inbound_interrupt_over_ws(adapter): + """WS-only inbound: connect() registers BOTH the inbound message handler AND + the interrupt_inbound handler on the transport, so a connector-delivered + interrupt_inbound frame (no HTTP receiver) reaches the right session.""" + await adapter.connect() + stub = adapter._transport + # Both connector->gateway handlers are wired post-connect. + assert stub._inbound is not None + assert stub._interrupt_inbound is not None + + key = "agent:main:discord:group:chanA:userX" + ev = asyncio.Event() + adapter._active_sessions[key] = ev + + # Simulate the connector pushing an interrupt_inbound frame down the WS. + await stub.push_interrupt(key, chat_id="chanA") + assert ev.is_set() is True, "interrupt delivered over the WS must cancel the target turn" diff --git a/tests/gateway/relay/test_relay_sheds_crypto.py b/tests/gateway/relay/test_relay_sheds_crypto.py index f2e0810af4a1..4af7d7368baa 100644 --- a/tests/gateway/relay/test_relay_sheds_crypto.py +++ b/tests/gateway/relay/test_relay_sheds_crypto.py @@ -48,16 +48,14 @@ def _relay_py_files() -> list[Path]: # ``auth.py`` is the connector⇄gateway CHANNEL authenticator (the gateway's WS -# upgrade bearer + inbound-delivery signature verification). ``inbound_receiver.py`` -# is the signed-inbound-delivery receiver that USES that channel auth to verify -# connector→gateway POSTs. Both are net-new, intended, and the whole point of -# authenticating an untrusted/disposable gateway — they are NOT platform crypto. -# They use HMAC over the connector's per-gateway / per-tenant secrets (NOT any -# platform's signing secret), so they are exempt from the platform-crypto symbol -# scan below. The module-import ban (platform-crypto modules) still applies to -# every file including these — they import only stdlib hmac/hashlib and each -# other, never a platform-crypto module, so they stay clean there. -_CHANNEL_AUTH_FILES = {"auth.py", "inbound_receiver.py"} +# upgrade bearer). It is net-new, intended, and the whole point of +# authenticating an untrusted/disposable gateway — it is NOT platform crypto. +# It uses HMAC over the connector's per-gateway secret (NOT any platform's +# signing secret), so it is exempt from the platform-crypto symbol scan below. +# The module-import ban (platform-crypto modules) still applies to every file +# including this one — it imports only stdlib hmac/hashlib, never a +# platform-crypto module, so it stays clean there. +_CHANNEL_AUTH_FILES = {"auth.py"} def test_relay_package_imports_no_platform_crypto(): diff --git a/tests/gateway/relay/test_self_provision.py b/tests/gateway/relay/test_self_provision.py index 4b4a6070e7ef..7a379eb5c3b7 100644 --- a/tests/gateway/relay/test_self_provision.py +++ b/tests/gateway/relay/test_self_provision.py @@ -8,6 +8,8 @@ from __future__ import annotations +import os + import pytest import gateway.relay as relay @@ -126,8 +128,9 @@ def test_provisions_and_sets_env_in_process(monkeypatch): # Creds landed in os.environ (in-process), so register_relay_adapter() reads them. gid, secret = relay.relay_connection_auth() assert gid and secret == "a" * 64 - key, _host, _port = relay.relay_inbound_config() - assert key == "b" * 64 + # The delivery key is persisted in-process too (issued by the connector, + # kept for forward-compat; inbound rides the WS so it isn't consumed). + assert os.environ["GATEWAY_RELAY_DELIVERY_KEY"] == "b" * 64 def test_outbound_only_when_no_endpoint(monkeypatch): From 36851fa576eb4079f0397010f418cafa15a4ab26 Mon Sep 17 00:00:00 2001 From: Evo Date: Fri, 19 Jun 2026 08:52:16 +0800 Subject: [PATCH 16/28] fix(docker): support WebUI installs from read-only sources (#48541) --- .dockerignore | 3 - setup.py | 59 +++++++++++++++ tests/test_docker_webui_install_surface.py | 87 ++++++++++++++++++++++ 3 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 tests/test_docker_webui_install_surface.py diff --git a/.dockerignore b/.dockerignore index f6fbbc9f137c..a5b50068f020 100644 --- a/.dockerignore +++ b/.dockerignore @@ -102,6 +102,3 @@ acp_registry/ .gitattributes .hadolint.yaml .mailmap - -# Top-level LICENSE (not matched by *.md); not needed inside the container -LICENSE diff --git a/setup.py b/setup.py index 8487f76e86f8..6e3e8c4272e8 100644 --- a/setup.py +++ b/setup.py @@ -2,13 +2,68 @@ from collections import defaultdict from pathlib import Path +import tempfile from setuptools import setup +from setuptools.command.build import build as _build +from setuptools.command.egg_info import egg_info as _egg_info REPO_ROOT = Path(__file__).parent.resolve() +def _source_tree_is_writable() -> bool: + probe = REPO_ROOT / ".setuptools-write-probe" + try: + with probe.open("w", encoding="utf-8") as handle: + handle.write("") + probe.unlink() + except OSError: + try: + probe.unlink(missing_ok=True) + except OSError: + pass + return False + return True + + +def _temporary_build_dir(kind: str) -> str: + return tempfile.mkdtemp(prefix=f"hermes-agent-{kind}-") + + +def _would_write_under_source(path_value: str | None) -> bool: + if path_value is None: + return True + path = Path(path_value) + if not path.is_absolute(): + path = REPO_ROOT / path + try: + path.resolve().relative_to(REPO_ROOT) + except ValueError: + return False + return True + + +class ReadOnlySourceBuild(_build): + def finalize_options(self) -> None: + if ( + not _source_tree_is_writable() + and _would_write_under_source(self.build_base) + ): + self.build_base = _temporary_build_dir("build") + super().finalize_options() + + +class ReadOnlySourceEggInfo(_egg_info): + def finalize_options(self) -> None: + if ( + not _source_tree_is_writable() + and _would_write_under_source(self.egg_base) + ): + self.egg_base = _temporary_build_dir("egg-info") + super().finalize_options() + + def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]: root = REPO_ROOT / root_name grouped: defaultdict[str, list[str]] = defaultdict(list) @@ -21,6 +76,10 @@ def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]: setup( + cmdclass={ + "build": ReadOnlySourceBuild, + "egg_info": ReadOnlySourceEggInfo, + }, data_files=[ *_data_file_tree("skills"), *_data_file_tree("optional-skills"), diff --git a/tests/test_docker_webui_install_surface.py b/tests/test_docker_webui_install_surface.py new file mode 100644 index 000000000000..413bfdaf0718 --- /dev/null +++ b/tests/test_docker_webui_install_surface.py @@ -0,0 +1,87 @@ +"""Guards for the multi-container Hermes WebUI install surface.""" + +from __future__ import annotations + +from pathlib import Path +import runpy + +from setuptools import Distribution +import setuptools + + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _is_under(path: str, root: Path) -> bool: + try: + Path(path).resolve().relative_to(root.resolve()) + except ValueError: + return False + return True + + +def test_docker_context_includes_license_file() -> None: + """PEP 639 license-files metadata must resolve inside the Docker image.""" + dockerignore = (REPO_ROOT / ".dockerignore").read_text(encoding="utf-8") + active_lines = [ + line.strip() + for line in dockerignore.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + + assert "LICENSE" not in active_lines + + +def test_setup_uses_temporary_outputs_when_source_tree_is_read_only( + monkeypatch, +) -> None: + """WebUI installs from read-only /opt/hermes must not write build metadata.""" + captured: dict[str, object] = {} + + def capture_setup(**kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr(setuptools, "setup", capture_setup) + namespace = runpy.run_path(str(REPO_ROOT / "setup.py")) + + cmdclass = captured["cmdclass"] + monkeypatch.setitem( + cmdclass["build"].finalize_options.__globals__, + "_source_tree_is_writable", + lambda: False, + ) + monkeypatch.setitem( + cmdclass["egg_info"].finalize_options.__globals__, + "_source_tree_is_writable", + lambda: False, + ) + + build_cmd = cmdclass["build"](Distribution()) + build_cmd.initialize_options() + build_cmd.finalize_options() + assert not _is_under(build_cmd.build_base, REPO_ROOT) + assert Path(build_cmd.build_base).name.startswith("hermes-agent-build") + + source_relative_build = cmdclass["build"](Distribution()) + source_relative_build.initialize_options() + source_relative_build.build_base = "nested/build" + source_relative_build.finalize_options() + assert not _is_under(source_relative_build.build_base, REPO_ROOT) + assert Path(source_relative_build.build_base).name.startswith("hermes-agent-build") + + egg_info_cmd = cmdclass["egg_info"](Distribution()) + egg_info_cmd.initialize_options() + egg_info_cmd.finalize_options() + assert egg_info_cmd.egg_base is not None + assert not _is_under(egg_info_cmd.egg_base, REPO_ROOT) + assert Path(egg_info_cmd.egg_base).name.startswith("hermes-agent-egg-info") + + source_relative_egg_info = cmdclass["egg_info"](Distribution()) + source_relative_egg_info.initialize_options() + source_relative_egg_info.egg_base = "." + source_relative_egg_info.finalize_options() + assert source_relative_egg_info.egg_base is not None + assert not _is_under(source_relative_egg_info.egg_base, REPO_ROOT) + assert Path(source_relative_egg_info.egg_base).name.startswith( + "hermes-agent-egg-info" + ) From 2c6e266e8829f9aaff1be4666afdbb05ca15fc6d Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Fri, 19 Jun 2026 11:01:24 +1000 Subject: [PATCH 17/28] fix(relay): trigger self-provision on relay-config + NAS token, not is_managed() (#48724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit self_provision_if_managed() gated on is_managed(), but is_managed() means "NixOS/package-manager-managed" (it keys on HERMES_MANAGED or a ~/.hermes/.managed marker) — NOT "NAS-hosted". A NAS-provisioned Fly agent sets NEITHER, so the gate was always False and relay self-provision SILENTLY no-oped on exactly the hosted agents it was built for. Caught live: a staging agent with GATEWAY_RELAY_URL correctly stamped logged "No messaging platforms enabled" and never dialed the connector; HERMES_MANAGED was unset on the machine. The unit tests had mocked is_managed()->True, so they passed while the real trigger never fired (mocked- trigger blind spot). Fix: drop the is_managed() gate and rename self_provision_if_managed -> self_provision_relay. The real trigger is now "relay_url() set + no pinned secret + a resolvable NAS token", which is both NAS-independent and self-guarding: - NAS-hosted agent: GATEWAY_RELAY_URL + no pinned secret + bootstrapped NAS token -> self-provisions. - Self-hosted + `hermes gateway enroll`: pinned GATEWAY_RELAY_SECRET -> skipped (existing secret-present guard). - Self-hosted, unenrolled, no NAS identity: resolve_nous_access_token() fails -> graceful no-op (existing fail-soft path). Security: unchanged trust model. The connector still derives tenant from the validated NAS token; this only broadens WHEN the provision attempt fires, and every broadened case is still guarded by token-resolution + pinned-secret-skip. Tests: replaced the (wrong) "skips when not managed" test with a regression test proving a NAS host where is_managed()==False STILL provisions; renamed all call sites; added a "no NAS token -> non-fatal skip" test for the self-hosted branch. 88 relay tests pass. Relay-adapter lane. EXPERIMENTAL. --- gateway/relay/__init__.py | 44 ++++++++++------- gateway/run.py | 13 ++--- tests/gateway/relay/test_self_provision.py | 56 +++++++++++++++------- 3 files changed, 71 insertions(+), 42 deletions(-) diff --git a/gateway/relay/__init__.py b/gateway/relay/__init__.py index a0bd4f526eff..4b3fdda8a8d7 100644 --- a/gateway/relay/__init__.py +++ b/gateway/relay/__init__.py @@ -204,21 +204,33 @@ def _post_provision( return payload -def self_provision_if_managed() -> bool: - """Managed-boot self-provision: mint relay creds in-process, no human, no disk. +def self_provision_relay() -> bool: + """Boot-time relay self-provision: mint relay creds in-process, no human, no disk. - Fires only on a MANAGED boot (``is_managed()``) with relay configured - (``relay_url()`` set) and NO per-gateway secret already present. In that case - the runtime resolves the agent's own Nous access token (the same + Fires when relay is configured (``relay_url()`` set) and NO per-gateway secret + is already present, AND the agent can resolve its own Nous access token. In + that case the runtime resolves the agent's own Nous access token (the same ``resolve_nous_access_token()`` the enroll CLI / dashboard register use), POSTs ``/relay/provision`` asserting its own endpoint + route keys, and sets ``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` / ``GATEWAY_RELAY_DELIVERY_KEY`` into ``os.environ`` so the subsequent ``register_relay_adapter()`` picks them - up. The creds live ONLY in process memory — never written to ``~/.hermes/.env`` - (``save_env_value`` refuses under managed anyway, and keeping the secret off - any volume is the stronger posture). - - Stateless: process-env creds don't survive a restart, so a managed container + up. The creds live ONLY in process memory — never written to ``~/.hermes/.env``. + + The trigger is deliberately NOT ``is_managed()``: that means + "package-manager/NixOS-managed" and is False on a NAS-hosted Fly agent (which + sets neither ``HERMES_MANAGED`` nor a ``.managed`` marker), so gating on it + blocked the exact hosted case this is for. The real signal is "you pointed me + at a connector and didn't pin a secret" — which is both NAS-independent and + self-guarding: + + - A NAS-hosted agent: has ``GATEWAY_RELAY_URL``, no pinned secret, and a + bootstrapped NAS token -> self-provisions. + - A self-hosted operator who ran ``hermes gateway enroll``: has a PINNED + ``GATEWAY_RELAY_SECRET`` -> skipped (the secret-present guard below). + - A self-hosted box with a relay URL but no NAS identity: + ``resolve_nous_access_token()`` fails -> graceful no-op. + + Stateless: process-env creds don't survive a restart, so a hosted container re-provisions every boot; the connector's rotation window covers a still- connected prior instance. An explicitly-pinned ``GATEWAY_RELAY_SECRET`` (env or config) is RESPECTED — self-provision skips so an operator pin isn't @@ -233,18 +245,12 @@ def self_provision_if_managed() -> bool: logger = logging.getLogger("gateway.relay") - try: - from hermes_cli.config import is_managed - except Exception: # noqa: BLE001 - return False - - if not is_managed(): - return False dial_url = relay_url() if not dial_url: return False - # Respect an already-present (pinned/stamped) secret — don't stomp it. + # Respect an already-present (pinned/stamped) secret — don't stomp it. This + # is also what makes a self-hosted, enrolled gateway skip self-provision. existing_id, existing_secret = relay_connection_auth() if existing_id and existing_secret: logger.info("relay self-provision skipped: GATEWAY_RELAY_SECRET already set") @@ -255,6 +261,8 @@ def self_provision_if_managed() -> bool: access_token = resolve_nous_access_token() except Exception as exc: # noqa: BLE001 - boot must survive a token failure + # No resolvable NAS identity (e.g. a self-hosted box that hasn't enrolled) + # -> nothing to provision with; skip quietly and let the gateway boot. logger.warning("relay self-provision skipped: could not resolve Nous token (%s)", exc) return False diff --git a/gateway/run.py b/gateway/run.py index 8f1393417930..e24afd035e7f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5119,14 +5119,15 @@ async def start(self) -> bool: from gateway.relay import ( register_relay_adapter, relay_url, - self_provision_if_managed, + self_provision_relay, ) - # Managed boot: self-provision relay creds in-process (resolve the - # agent's NAS token -> POST /relay/provision -> set GATEWAY_RELAY_* in - # os.environ) BEFORE registration reads them. No-op when not managed, - # relay unconfigured, or a secret is already pinned. Never raises. - self_provision_if_managed() + # Boot-time relay self-provision: resolve the agent's NAS token -> + # POST /relay/provision -> set GATEWAY_RELAY_* in os.environ BEFORE + # registration reads them. No-op when relay is unconfigured, a secret + # is already pinned, or no NAS token resolves (self-hosted, unenrolled). + # Never raises. + self_provision_relay() if register_relay_adapter(): logger.info("relay adapter registered (connector at %s)", relay_url()) diff --git a/tests/gateway/relay/test_self_provision.py b/tests/gateway/relay/test_self_provision.py index 7a379eb5c3b7..c5af66f94ef2 100644 --- a/tests/gateway/relay/test_self_provision.py +++ b/tests/gateway/relay/test_self_provision.py @@ -1,9 +1,13 @@ -"""Unit tests for managed-boot relay self-provisioning. +"""Unit tests for boot-time relay self-provisioning. -Covers gateway.relay.self_provision_if_managed() + the relay_endpoint() / +Covers gateway.relay.self_provision_relay() + the relay_endpoint() / relay_route_keys() config readers. The connector HTTP POST is monkeypatched (the cross-repo E2E exercises the real /relay/provision); these prove the TRIGGER logic, in-process env wiring, and fail-soft boot behaviour. + +The trigger is deliberately NOT is_managed() (that means NixOS/package-manager- +managed, which is False on a NAS-hosted Fly agent). The real gate is +"relay_url set + no pinned secret + a resolvable NAS token". """ from __future__ import annotations @@ -48,8 +52,13 @@ def _fake(**kwargs): return _fake -def _arm(monkeypatch, *, managed=True, url="wss://connector.example/relay", token="nas-token"): - monkeypatch.setattr("hermes_cli.config.is_managed", lambda: managed) +def _arm(monkeypatch, *, url="wss://connector.example/relay", token="nas-token"): + """Arm the real trigger: a relay URL + a resolvable NAS token. + + Note there is intentionally no `managed` knob — self-provision no longer + consults is_managed(). A test that wants the "no NAS identity" branch + monkeypatches resolve_nous_access_token to raise instead. + """ monkeypatch.setattr(relay, "relay_url", lambda: url) monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: token) @@ -82,29 +91,37 @@ def test_provision_url_maps_ws_to_http(): # ─────────────────────────── trigger logic ─────────────────────────── -def test_skips_when_not_managed(monkeypatch): - _arm(monkeypatch, managed=False) - called = {"n": 0} - monkeypatch.setattr(relay, "_post_provision", lambda **k: called.__setitem__("n", called["n"] + 1) or {}) - assert relay.self_provision_if_managed() is False - assert called["n"] == 0 +def test_provisions_on_nas_host_that_is_NOT_is_managed(monkeypatch): + """Regression: a NAS-hosted Fly agent sets neither HERMES_MANAGED nor a + .managed marker, so is_managed() is False. Self-provision must STILL fire — + the old is_managed() gate silently no-oped exactly this case in staging. + """ + # Force is_managed() False to model a real hosted agent; it must be irrelevant. + monkeypatch.setattr("hermes_cli.config.is_managed", lambda: False) + _arm(monkeypatch) + captured: dict = {} + monkeypatch.setattr(relay, "_post_provision", _stub_post(captured)) + + assert relay.self_provision_relay() is True + assert relay.relay_connection_auth()[1] == "a" * 64 def test_skips_when_relay_not_configured(monkeypatch): _arm(monkeypatch, url=None) called = {"n": 0} monkeypatch.setattr(relay, "_post_provision", lambda **k: called.__setitem__("n", called["n"] + 1) or {}) - assert relay.self_provision_if_managed() is False + assert relay.self_provision_relay() is False assert called["n"] == 0 def test_skips_when_secret_already_pinned(monkeypatch): + """A self-hosted, enrolled gateway has a pinned secret -> never self-provisions.""" _arm(monkeypatch) monkeypatch.setenv("GATEWAY_RELAY_ID", "gw-pinned") monkeypatch.setenv("GATEWAY_RELAY_SECRET", "deadbeef") called = {"n": 0} monkeypatch.setattr(relay, "_post_provision", lambda **k: called.__setitem__("n", called["n"] + 1) or {}) - assert relay.self_provision_if_managed() is False + assert relay.self_provision_relay() is False assert called["n"] == 0 # The pinned secret is untouched. assert relay.relay_connection_auth() == ("gw-pinned", "deadbeef") @@ -119,7 +136,7 @@ def test_provisions_and_sets_env_in_process(monkeypatch): captured: dict = {} monkeypatch.setattr(relay, "_post_provision", _stub_post(captured)) - assert relay.self_provision_if_managed() is True + assert relay.self_provision_relay() is True # The connector POST carried the gateway-asserted endpoint + route keys. assert captured["provision_url"] == "https://connector.example/relay/provision" assert captured["access_token"] == "nas-token" @@ -138,7 +155,7 @@ def test_outbound_only_when_no_endpoint(monkeypatch): captured: dict = {} monkeypatch.setattr(relay, "_post_provision", _stub_post(captured)) - assert relay.self_provision_if_managed() is True + assert relay.self_provision_relay() is True assert captured["gateway_endpoint"] is None assert captured["route_keys"] == [] assert relay.relay_connection_auth()[1] == "a" * 64 @@ -146,15 +163,18 @@ def test_outbound_only_when_no_endpoint(monkeypatch): # ─────────────────────────── fail-soft ─────────────────────────── -def test_token_failure_is_non_fatal(monkeypatch): - _arm(monkeypatch) +def test_no_nas_token_is_non_fatal(monkeypatch): + """A self-hosted box with a relay URL but no resolvable NAS identity skips + quietly (this is the branch that replaces the old is_managed() gate for the + non-NAS case).""" + monkeypatch.setattr(relay, "relay_url", lambda: "wss://connector.example/relay") def _boom(): raise RuntimeError("no token") monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", _boom) # Must not raise; returns False; no creds set. - assert relay.self_provision_if_managed() is False + assert relay.self_provision_relay() is False assert relay.relay_connection_auth() == (None, None) @@ -165,5 +185,5 @@ def _boom(**kwargs): raise RuntimeError("connector returned HTTP 503") monkeypatch.setattr(relay, "_post_provision", _boom) - assert relay.self_provision_if_managed() is False + assert relay.self_provision_relay() is False assert relay.relay_connection_auth() == (None, None) From e8855d41ec062f9c1b2e8406ae435e0a69b18208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B3=AF=E5=B2=B8=E3=80=80=E4=BA=AE?= <1920071390@campus.ouj.ac.jp> Date: Fri, 19 Jun 2026 11:13:45 +0900 Subject: [PATCH 18/28] Fix CI lock and desktop session test fixture --- .../hooks/use-session-actions.test.tsx | 1 + pyproject.toml | 4 +- uv.lock | 41 ++++++++++++++++--- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index e6c10dccb5ce..7b3a278be2fb 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -148,6 +148,7 @@ function ResumeHarness({ selectedStoredSessionIdRef: ref(null), sessionStateByRuntimeIdRef: ref(new Map()), syncSessionStateToView: vi.fn(), + resetViewSync: vi.fn(), updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState) }) diff --git a/pyproject.toml b/pyproject.toml index f4c98d1cb55c..2fd4a98ca5f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,7 +116,7 @@ dependencies = [ # FastAPI's UploadFile/Form depend on python-multipart; it is NOT pulled in # by fastapi itself, so the dashboard's multipart upload endpoint would 500 # without an explicit dependency here (and in the `web` extra below). - "python-multipart>=0.0.9,<1", + "python-multipart>=0.0.32,<1", "ptyprocess>=0.7.0,<1; sys_platform != 'win32'", "pywinpty>=2.0.0,<3; sys_platform == 'win32'", # Image resize recovery for the vision tools. Pillow shrinks oversized images @@ -295,7 +295,7 @@ youtube = [ # `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean. # starlette==1.3.1 pinned for CVE-2026-48710 (BadHost) — fastapi pulls Starlette # transitively and pre-1.0.1 is the vulnerable range. See the mcp extra above. -web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.3.1", "python-multipart==0.0.20"] +web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.3.1", "python-multipart>=0.0.32,<1"] all = [ # Policy (2026-05-12): `[all]` includes only extras that genuinely # CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every diff --git a/uv.lock b/uv.lock index b22b991fa2d9..467f091aac46 100644 --- a/uv.lock +++ b/uv.lock @@ -769,7 +769,7 @@ name = "concurrent-log-handler" version = "0.9.29" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "portalocker" }, + { name = "portalocker", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/ba185acc438cff6b58cd8f8dec27e7f4fcabf6968a1facbb6d0cacbde7fe/concurrent_log_handler-0.9.29.tar.gz", hash = "sha256:bc37a76d3f384cbf4a98f693ebd770543edc0f4cd5c6ab6bc70e9e1d7d582265", size = 42114, upload-time = "2026-02-22T18:18:25.758Z" } wheels = [ @@ -2024,8 +2024,9 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = "==2.4.0" }, { name = "python-dotenv", specifier = "==1.2.2" }, - { name = "python-multipart", specifier = ">=0.0.9,<1" }, - { name = "python-multipart", marker = "extra == 'web'", specifier = "==0.0.20" }, + { name = "python-multipart", specifier = ">=0.0.32,<1" }, + { name = "python-multipart", marker = "extra == 'web'", specifier = ">=0.0.32,<1" }, + { name = "python-osc", marker = "extra == 'vrchat'", specifier = "==1.10.2" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" }, { name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0.0,<3" }, @@ -3568,6 +3569,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "polars" +version = "1.41.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/f9/aeda46259b0669247a160315d2d51269de9504b9dd2f70acadbcb22f46b7/polars-1.41.2.tar.gz", hash = "sha256:256d6731162371b77f3f29a55eacb8c0fc740ddb1a293a01d2ef5b5393c5c708", size = 737996, upload-time = "2026-05-29T17:39:15.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/22/28f62d24f7db56ac4343588f9362d49b7b4177e55ac47a466fe696b0099b/polars-1.41.2-py3-none-any.whl", hash = "sha256:23ce9a2910b6e3e8d4258770bf44aa17170958df7af6e85feedf4458a04d8d29", size = 833445, upload-time = "2026-05-29T17:37:05.576Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.41.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/56/54e3ea0e9b64f327179049e4742241cc6b1d3e8fa414b05a057dd26df367/polars_runtime_32-1.41.2.tar.gz", hash = "sha256:7af09ec1ab053da2c9669e8d15f809a4083a29be05db57111688b8051062af56", size = 2989474, upload-time = "2026-05-29T17:39:17.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/9b/fe72a3811c0357cdb06c67bdc7695fa1623ad47948fc523195f5ac31037f/polars_runtime_32-1.41.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:95a08346dac337357cdb825c8076df7d36da54c4caa59a5cb41d0a30691c5edd", size = 52265283, upload-time = "2026-05-29T17:37:09.407Z" }, + { url = "https://files.pythonhosted.org/packages/0a/93/fab9da803fd80d9e83ef88c20932f637a10bc611b20415fc322eec84bc44/polars_runtime_32-1.41.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:dedfaeec2c7f995298da7319dd9431d662e5dd1d0ec51b1459df4a0234ceff52", size = 46571222, upload-time = "2026-05-29T17:37:13.698Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/8843f34a8ac57acd058a39b87b03b580dd352a490e9dae0415e02033bdd4/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18eea22c5cc34e27f8a60950458ad81e6a9ea75e89363ca1367e14e7e7f781fc", size = 50409372, upload-time = "2026-05-29T17:37:17.875Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c6/92b352fe88cf51bd0a19fb99e1c0cbe46aa26c14dcf7995b89869cd932ae/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2630540dfdfb0f36f9b04a07c7c2e3f50bf2ad384113263c1c812007ee9141e0", size = 56405484, upload-time = "2026-05-29T17:37:22.684Z" }, + { url = "https://files.pythonhosted.org/packages/74/c4/bae3174c3b02f6b441d2e58594387abcd509f67a098f682a83b195f08966/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:20e969e08f9b137e233c04cc04de73d9795f89eb77d34854e40a025965a43763", size = 50603512, upload-time = "2026-05-29T17:37:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ed/f2d26ae02d92c2689056838ed59e2a626326ad23c2831d58637d25f6c82a/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e7016a3deb641b64a31447abbbee0f34bd020a6a9ae34ee6b743837def15e2a4", size = 54328561, upload-time = "2026-05-29T17:37:32.587Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c4/9c3831cc885dc7769e59abf8f583821a5fb4403fd0e4eba0ccc6d47a3d4b/polars_runtime_32-1.41.2-cp310-abi3-win_amd64.whl", hash = "sha256:1e5e5377c315e0dcafdfb2a31adc546abbaeb3f9cb1864e6536523d2af473265", size = 51978643, upload-time = "2026-05-29T17:37:37.443Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c6/79e9f3f270270d7ed5575d92b7bfef49f01abd9275447161275b23b553a8/polars_runtime_32-1.41.2-cp310-abi3-win_arm64.whl", hash = "sha256:843d96f69d18eca53429c1198e58891db7f18111f83b9c419bb45ad9d73eaed5", size = 46006901, upload-time = "2026-05-29T17:37:42.522Z" }, +] + [[package]] name = "portalocker" version = "3.2.0" @@ -4074,11 +4103,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] From 0403f41f9cc4b3e51d9e58c889bbd669aeabdb48 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Tue, 16 Jun 2026 12:13:39 +0800 Subject: [PATCH 19/28] fix(agent): handle missing trigram tokenizer without disabling FTS5 _is_fts5_unavailable_error only matched 'no such module: fts5', but SQLite builds that ship FTS5 without the optional trigram tokenizer raise 'no such tokenizer: trigram' instead. This caused SessionDB init to crash on those builds. Additionally, the trigram failure path called _warn_fts5_unavailable which set _fts_enabled = False, globally disabling full-text search even though the base FTS5 table was created successfully. Fix: - Extend _is_fts5_unavailable_error to also match 'no such tokenizer' - Add _is_tokenizer_unavailable_error to distinguish tokenizer-specific failures from whole-module absence - Only call _warn_fts5_unavailable for module-level failures; skip it for tokenizer-specific failures so base FTS5 remains usable Fixes #47002 --- hermes_state.py | 26 +++++++++++++++--- tests/test_hermes_state.py | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 19c6a269b99e..f54fbbd6af56 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -772,7 +772,18 @@ def _connect_and_init(): @staticmethod def _is_fts5_unavailable_error(exc: sqlite3.OperationalError) -> bool: err = str(exc).lower() - return "no such module" in err and "fts5" in err + if "no such module" in err and "fts5" in err: + return True + # SQLite builds that have FTS5 but lack the optional trigram tokenizer + # raise "no such tokenizer: trigram" instead of "no such module". + if "no such tokenizer" in err: + return True + return False + + @staticmethod + def _is_tokenizer_unavailable_error(exc: sqlite3.OperationalError) -> bool: + """Check if the error is about a specific tokenizer (not the whole FTS5 module).""" + return "no such tokenizer" in str(exc).lower() def _warn_fts5_unavailable(self, exc: sqlite3.OperationalError) -> None: self._fts_enabled = False @@ -844,7 +855,9 @@ def _fts_table_probe(self, cursor: sqlite3.Cursor, table_name: str) -> Optional[ return True except sqlite3.OperationalError as exc: if self._is_fts5_unavailable_error(exc): - self._warn_fts5_unavailable(exc) + # Only disable FTS entirely when the whole module is missing. + if not self._is_tokenizer_unavailable_error(exc): + self._warn_fts5_unavailable(exc) return None if "no such table" in str(exc).lower(): return False @@ -868,7 +881,11 @@ def _ensure_fts_schema( except sqlite3.OperationalError as exc: if not self._is_fts5_unavailable_error(exc): raise - self._warn_fts5_unavailable(exc) + # Only disable FTS entirely when the whole FTS5 module is missing. + # A missing specific tokenizer (e.g. trigram) means only that + # particular table cannot be created — the base FTS5 table is fine. + if not self._is_tokenizer_unavailable_error(exc): + self._warn_fts5_unavailable(exc) return False def _execute_write(self, fn: Callable[[sqlite3.Connection], T]) -> T: @@ -1166,7 +1183,8 @@ def _init_schema(self): except sqlite3.OperationalError as exc: if not self._is_fts5_unavailable_error(exc): raise - self._warn_fts5_unavailable(exc) + if not self._is_tokenizer_unavailable_error(exc): + self._warn_fts5_unavailable(exc) fts5_available = False fts_migrations_complete = False break diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 3644308401f3..4bdc12d46428 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -50,6 +50,20 @@ def cursor(self, factory=None): return super().cursor(factory or _NoFtsExistingTableCursor) +class _NoTrigramCursor(sqlite3.Cursor): + """Simulate a SQLite build with FTS5 but without the trigram tokenizer.""" + + def executescript(self, sql_script): + if "tokenize='trigram'" in sql_script: + raise sqlite3.OperationalError("no such tokenizer: trigram") + return super().executescript(sql_script) + + +class _NoTrigramConnection(sqlite3.Connection): + def cursor(self, factory=None): + return super().cursor(factory or _NoTrigramCursor) + + @pytest.fixture() def db(tmp_path): """Create a SessionDB with a temp database file.""" @@ -330,6 +344,46 @@ def connect_without_fts(*args, **kwargs): finally: restored.close() + def test_is_fts5_unavailable_error_catches_trigram_tokenizer(self): + """Unit test: _is_fts5_unavailable_error matches 'no such tokenizer'.""" + fts5_err = sqlite3.OperationalError("no such module: fts5") + trigram_err = sqlite3.OperationalError("no such tokenizer: trigram") + unrelated_err = sqlite3.OperationalError("no such table: foo") + + assert SessionDB._is_fts5_unavailable_error(fts5_err) is True + assert SessionDB._is_fts5_unavailable_error(trigram_err) is True + assert SessionDB._is_fts5_unavailable_error(unrelated_err) is False + + def test_db_initializes_without_trigram_tokenizer(self, tmp_path, monkeypatch): + """SessionDB must not crash when FTS5 exists but trigram tokenizer is missing.""" + real_connect = sqlite3.connect + + def connect_without_trigram(*args, **kwargs): + kwargs["factory"] = _NoTrigramConnection + return real_connect(*args, **kwargs) + + monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram) + + db = SessionDB(db_path=tmp_path / "state.db") + try: + # Base FTS5 should still work (trigram is optional). + assert db._fts_enabled is True + assert db._fts_table_exists("messages_fts") is True + # Trigram table should NOT have been created. + assert db._fts_table_exists("messages_fts_trigram") is False + + db.create_session(session_id="s1", source="cli") + db.append_message("s1", role="user", content="hello without trigram") + + messages = db.get_messages("s1") + assert len(messages) == 1 + assert messages[0]["content"] == "hello without trigram" + + # FTS5 keyword search should still work. + assert len(db.search_messages("hello")) == 1 + finally: + db.close() + # ========================================================================= # Message storage From c10aa5dc9c69e8e2cc03178be4b189844df29965 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Tue, 16 Jun 2026 12:47:07 +0800 Subject: [PATCH 20/28] fix(agent): address review feedback on trigram tokenizer fallback - Scope 'no such tokenizer' matcher to trigram specifically (#779) - Decouple base FTS and trigram backfill in v11 migration (#1195) - CJK search falls back to LIKE when trigram unavailable (#3384/#3430) - Add _trigram_available tracking across init, migration, and startup - Add regression tests for migration backfill and CJK LIKE fallback - Add _is_trigram_unavailable_error and _warn_trigram_unavailable helpers --- hermes_state.py | 76 ++++++++++++++++++++++++---------- tests/test_hermes_state.py | 84 +++++++++++++++++++++++++++++++++++++- 2 files changed, 138 insertions(+), 22 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index f54fbbd6af56..99cb24748e6c 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -684,6 +684,7 @@ def __init__(self, db_path: Path = None, read_only: bool = False): self._lock = threading.Lock() self._write_count = 0 self._fts_enabled = False + self._trigram_available = False self._fts_unavailable_warned = False self._conn = None try: @@ -776,14 +777,29 @@ def _is_fts5_unavailable_error(exc: sqlite3.OperationalError) -> bool: return True # SQLite builds that have FTS5 but lack the optional trigram tokenizer # raise "no such tokenizer: trigram" instead of "no such module". - if "no such tokenizer" in err: + # Scope to trigram specifically to avoid masking unrelated tokenizer errors. + if "no such tokenizer: trigram" in err: return True return False @staticmethod - def _is_tokenizer_unavailable_error(exc: sqlite3.OperationalError) -> bool: - """Check if the error is about a specific tokenizer (not the whole FTS5 module).""" - return "no such tokenizer" in str(exc).lower() + def _is_trigram_unavailable_error(exc: sqlite3.OperationalError) -> bool: + """True when only the trigram tokenizer is missing (FTS5 itself works).""" + return "no such tokenizer: trigram" in str(exc).lower() + + def _warn_trigram_unavailable(self, exc: sqlite3.OperationalError) -> None: + """Log once that the trigram tokenizer is missing; base FTS5 stays enabled.""" + if getattr(self, "_trigram_unavailable_warned", False): + return + self._trigram_unavailable_warned = True + logger.info( + "SQLite trigram tokenizer unavailable for %s " + "(requires SQLite >= 3.34, this build is %s); " + "CJK/substring search will fall back to LIKE: %s", + self.db_path, + sqlite3.sqlite_version, + exc, + ) def _warn_fts5_unavailable(self, exc: sqlite3.OperationalError) -> None: self._fts_enabled = False @@ -856,7 +872,10 @@ def _fts_table_probe(self, cursor: sqlite3.Cursor, table_name: str) -> Optional[ except sqlite3.OperationalError as exc: if self._is_fts5_unavailable_error(exc): # Only disable FTS entirely when the whole module is missing. - if not self._is_tokenizer_unavailable_error(exc): + # A missing trigram tokenizer only affects trigram searches. + if self._is_trigram_unavailable_error(exc): + self._warn_trigram_unavailable(exc) + else: self._warn_fts5_unavailable(exc) return None if "no such table" in str(exc).lower(): @@ -884,7 +903,9 @@ def _ensure_fts_schema( # Only disable FTS entirely when the whole FTS5 module is missing. # A missing specific tokenizer (e.g. trigram) means only that # particular table cannot be created — the base FTS5 table is fine. - if not self._is_tokenizer_unavailable_error(exc): + if self._is_trigram_unavailable_error(exc): + self._warn_trigram_unavailable(exc) + else: self._warn_fts5_unavailable(exc) return False @@ -1183,22 +1204,23 @@ def _init_schema(self): except sqlite3.OperationalError as exc: if not self._is_fts5_unavailable_error(exc): raise - if not self._is_tokenizer_unavailable_error(exc): + if self._is_trigram_unavailable_error(exc): + self._warn_trigram_unavailable(exc) + else: self._warn_fts5_unavailable(exc) - fts5_available = False - fts_migrations_complete = False + fts5_available = False + fts_migrations_complete = False break if fts5_available: # Recreate virtual tables + triggers with the new inline-mode # schema that indexes content || tool_name || tool_calls. - if ( - self._ensure_fts_schema(cursor, "messages_fts", FTS_SQL) - and self._ensure_fts_schema( - cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL - ) - ): - # Backfill both indexes from every existing messages row. + # Handle base and trigram independently — a missing + # trigram tokenizer should not prevent base FTS backfill. + base_fts_ok = self._ensure_fts_schema( + cursor, "messages_fts", FTS_SQL + ) + if base_fts_ok: cursor.execute( "INSERT INTO messages_fts(rowid, content) " "SELECT id, " @@ -1207,6 +1229,10 @@ def _init_schema(self): "COALESCE(tool_calls, '') " "FROM messages" ) + trigram_ok = self._ensure_fts_schema( + cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL + ) + if trigram_ok: cursor.execute( "INSERT INTO messages_fts_trigram(rowid, content) " "SELECT id, " @@ -1215,8 +1241,12 @@ def _init_schema(self): "COALESCE(tool_calls, '') " "FROM messages" ) - else: + if not base_fts_ok: fts_migrations_complete = False + # Track trigram availability for CJK LIKE fallback. + self._trigram_available = trigram_ok + else: + fts_migrations_complete = False else: fts_migrations_complete = False if current_version < 12: @@ -1286,6 +1316,7 @@ def _init_schema(self): trigram_enabled = self._ensure_fts_schema( cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL ) + self._trigram_available = trigram_enabled if trigram_enabled and triggers_need_repair: self._rebuild_fts_indexes(cursor) @@ -3422,7 +3453,8 @@ def search_messages( self._count_cjk(t) < 3 for t in _tokens_for_check ) - if cjk_count >= 3 and not _any_short_cjk: + _trigram_succeeded = False + if cjk_count >= 3 and not _any_short_cjk and self._trigram_available: # Trigram FTS5 path — quote each non-operator token to handle # FTS5 special chars (%, *, etc.) while preserving boolean # operators (AND, OR, NOT) for multi-term queries. @@ -3471,11 +3503,13 @@ def search_messages( try: tri_cursor = self._conn.execute(tri_sql, tri_params) except sqlite3.OperationalError: - matches = [] + # Trigram query failed at runtime — fall through to LIKE. + pass else: matches = [dict(row) for row in tri_cursor.fetchall()] - else: - # Short / mixed CJK query: trigram cannot match tokens with + _trigram_succeeded = True + if not _trigram_succeeded: + # Short / mixed CJK query, trigram unavailable, or trigram # <3 CJK chars. Fall back to LIKE substring search. # For multi-token OR queries (e.g. "广西 OR 桂林 OR 漓江"), # build one LIKE condition per non-operator token so each term diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 4bdc12d46428..0baf3226401e 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -345,15 +345,28 @@ def connect_without_fts(*args, **kwargs): restored.close() def test_is_fts5_unavailable_error_catches_trigram_tokenizer(self): - """Unit test: _is_fts5_unavailable_error matches 'no such tokenizer'.""" + """Unit test: _is_fts5_unavailable_error matches 'no such tokenizer: trigram'.""" fts5_err = sqlite3.OperationalError("no such module: fts5") trigram_err = sqlite3.OperationalError("no such tokenizer: trigram") + generic_tokenizer_err = sqlite3.OperationalError("no such tokenizer: foo") unrelated_err = sqlite3.OperationalError("no such table: foo") assert SessionDB._is_fts5_unavailable_error(fts5_err) is True assert SessionDB._is_fts5_unavailable_error(trigram_err) is True + # Generic tokenizer errors should NOT match — only trigram. + assert SessionDB._is_fts5_unavailable_error(generic_tokenizer_err) is False assert SessionDB._is_fts5_unavailable_error(unrelated_err) is False + def test_is_trigram_unavailable_error(self): + """Unit test: _is_trigram_unavailable_error is scoped to trigram.""" + trigram_err = sqlite3.OperationalError("no such tokenizer: trigram") + generic_err = sqlite3.OperationalError("no such tokenizer: foo") + fts5_err = sqlite3.OperationalError("no such module: fts5") + + assert SessionDB._is_trigram_unavailable_error(trigram_err) is True + assert SessionDB._is_trigram_unavailable_error(generic_err) is False + assert SessionDB._is_trigram_unavailable_error(fts5_err) is False + def test_db_initializes_without_trigram_tokenizer(self, tmp_path, monkeypatch): """SessionDB must not crash when FTS5 exists but trigram tokenizer is missing.""" real_connect = sqlite3.connect @@ -384,6 +397,75 @@ def connect_without_trigram(*args, **kwargs): finally: db.close() + def test_v11_migration_backfills_base_fts_when_trigram_unavailable( + self, tmp_path, monkeypatch + ): + """Regression: v11 migration must backfill base FTS even when trigram is unavailable.""" + real_connect = sqlite3.connect + db_path = tmp_path / "state.db" + + # Phase 1: create a DB at schema v10 with messages. + db = SessionDB(db_path=db_path) + db.create_session(session_id="s1", source="cli") + db.append_message("s1", role="user", content="legacy message alpha") + db.append_message("s1", role="assistant", content="legacy reply beta") + # Force schema version to v10 so migration runs on next open. + db._conn.execute( + "UPDATE schema_version SET version = 10" + ) + db._conn.commit() + db.close() + + # Phase 2: reopen with trigram disabled — migration should still + # backfill base FTS and make existing messages searchable. + def connect_without_trigram(*args, **kwargs): + kwargs["factory"] = _NoTrigramConnection + return real_connect(*args, **kwargs) + + monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram) + migrated_db = SessionDB(db_path=db_path) + try: + assert migrated_db._fts_enabled is True + assert migrated_db._trigram_available is False + assert migrated_db._fts_table_exists("messages_fts") is True + assert migrated_db._fts_table_exists("messages_fts_trigram") is False + + # Existing messages must be searchable via base FTS. + results = migrated_db.search_messages("legacy message") + assert len(results) == 1 + # snippet has FTS5 highlight markers (>>>...<<<); check raw content via get_messages + msgs = migrated_db.get_messages("s1") + assert any("legacy message" in m["content"] for m in msgs) + finally: + migrated_db.close() + + def test_cjk_search_falls_back_to_like_when_trigram_unavailable( + self, tmp_path, monkeypatch + ): + """Regression: long CJK queries must fall back to LIKE when trigram is missing.""" + real_connect = sqlite3.connect + db_path = tmp_path / "state.db" + + def connect_without_trigram(*args, **kwargs): + kwargs["factory"] = _NoTrigramConnection + return real_connect(*args, **kwargs) + + monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram) + db = SessionDB(db_path=db_path) + try: + db.create_session(session_id="s1", source="cli") + db.append_message("s1", role="user", content="大别山项目计划书") + db.append_message("s1", role="user", content="长江大桥设计方案") + + # 3+ CJK chars would normally use trigram, but it's unavailable. + # Must fall back to LIKE and still return results. + results = db.search_messages("大别山") + assert len(results) == 1 + # Note: search_messages strips 'content' from results; use 'snippet'. + assert "大别山" in results[0]["snippet"] + finally: + db.close() + # ========================================================================= # Message storage From 9ae98e07a7ee7929f8ec3902c545c42d66f10268 Mon Sep 17 00:00:00 2001 From: channkim Date: Tue, 16 Jun 2026 14:06:26 +0900 Subject: [PATCH 21/28] fix(agent): rebuild base fts without trigram --- hermes_state.py | 19 ++++++++++++++----- tests/test_hermes_state.py | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 99cb24748e6c..36e5c91fe8a1 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -845,9 +845,12 @@ def _fts_trigger_count(cursor: sqlite3.Cursor) -> int: return int(row[0] if not isinstance(row, sqlite3.Row) else row[0]) @staticmethod - def _rebuild_fts_indexes(cursor: sqlite3.Cursor) -> None: - for table_name in ("messages_fts", "messages_fts_trigram"): - cursor.execute(f"DELETE FROM {table_name}") + def _rebuild_fts_indexes( + cursor: sqlite3.Cursor, + *, + include_trigram: bool = True, + ) -> None: + cursor.execute("DELETE FROM messages_fts") cursor.execute( "INSERT INTO messages_fts(rowid, content) " "SELECT id, " @@ -856,6 +859,9 @@ def _rebuild_fts_indexes(cursor: sqlite3.Cursor) -> None: "COALESCE(tool_calls, '') " "FROM messages" ) + if not include_trigram: + return + cursor.execute("DELETE FROM messages_fts_trigram") cursor.execute( "INSERT INTO messages_fts_trigram(rowid, content) " "SELECT id, " @@ -1317,8 +1323,11 @@ def _init_schema(self): cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL ) self._trigram_available = trigram_enabled - if trigram_enabled and triggers_need_repair: - self._rebuild_fts_indexes(cursor) + if triggers_need_repair: + self._rebuild_fts_indexes( + cursor, + include_trigram=trigram_enabled, + ) self._conn.commit() diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 0baf3226401e..e4650ed5dc79 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -344,6 +344,45 @@ def connect_without_fts(*args, **kwargs): finally: restored.close() + def test_base_fts_rebuilds_after_trigger_repair_without_trigram( + self, tmp_path, monkeypatch + ): + """Trigger repair must rebuild base FTS even when trigram is unavailable.""" + db_path = tmp_path / "state.db" + seeded = SessionDB(db_path=db_path) + try: + seeded.create_session(session_id="s1", source="cli") + seeded.append_message("s1", role="user", content="already indexed") + for trigger in ( + "messages_fts_insert", + "messages_fts_delete", + "messages_fts_update", + "messages_fts_trigram_insert", + "messages_fts_trigram_delete", + "messages_fts_trigram_update", + ): + seeded._conn.execute(f"DROP TRIGGER IF EXISTS {trigger}") + seeded._conn.commit() + seeded.append_message("s1", role="assistant", content="repair only base needle") + finally: + seeded.close() + + real_connect = sqlite3.connect + + def connect_without_trigram(*args, **kwargs): + kwargs["factory"] = _NoTrigramConnection + return real_connect(*args, **kwargs) + + monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram) + restored = SessionDB(db_path=db_path) + try: + assert restored._fts_enabled is True + assert restored._trigram_available is False + assert restored._fts_table_exists("messages_fts") is True + assert len(restored.search_messages("needle")) == 1 + finally: + restored.close() + def test_is_fts5_unavailable_error_catches_trigram_tokenizer(self): """Unit test: _is_fts5_unavailable_error matches 'no such tokenizer: trigram'.""" fts5_err = sqlite3.OperationalError("no such module: fts5") From 1d2e359678692204af91bb39677264cda8b9545d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:37:48 -0700 Subject: [PATCH 22/28] fix(cli): surface a visible warning when the session store is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When SessionDB init fails, the CLI/Desktop previously continued live with only a buried log line. The chat looks healthy, but the transcript is never written to state.db — so resume later shows a truncated or empty session and the user only discovers the loss after the fact (#41386). Emit a prominent stderr banner at startup when the store is unavailable, making it explicit that the conversation will not be saved and cannot be resumed, with a pointer to fix the store. Also set _session_db_unavailable so downstream code can detect the degraded state. --- cli.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/cli.py b/cli.py index b1c9a4bc8ef0..4e4ddb015c0f 100644 --- a/cli.py +++ b/cli.py @@ -3503,11 +3503,36 @@ def __init__( self._last_turn_finished_at: Optional[float] = None # time.time() when the last agent loop finished # Initialize SQLite session store early so /title works before first message self._session_db = None + self._session_db_unavailable = False try: from hermes_state import SessionDB self._session_db = SessionDB() except Exception as e: + # #41386: a failed session store means the transcript is NOT + # persisted to state.db — the live chat looks healthy but resume + # later shows a truncated/empty session. A buried log line is not + # enough; surface it prominently so the user knows persistence is + # off for this run and can fix the store before relying on resume. + self._session_db_unavailable = True logger.warning("Failed to initialize SessionDB — session will NOT be indexed for search: %s", e) + try: + # Console is imported at module scope; do NOT re-import it here. + # A function-local `import` would make `Console` a local name for + # the whole __init__ body and break the earlier `self.console = + # Console()` with UnboundLocalError. + Console(stderr=True).print( + "[bold yellow]⚠ Session store unavailable[/bold yellow] — " + "this conversation will [bold]NOT be saved[/bold] to disk and " + "cannot be resumed later. Searching past sessions is also disabled.\n" + f" Reason: {e}\n" + " Fix the state.db store (e.g. `hermes update` to rebuild the venv) to restore persistence." + ) + except Exception: + # Never let the warning path itself break startup. + print( + "WARNING: Session store unavailable — this conversation will NOT be " + f"saved to disk and cannot be resumed later. Reason: {e}" + ) # Opportunistic state.db maintenance — runs at most once per # min_interval_hours, tracked via state_meta in state.db itself so From 62c71ebd8f5a57857357c1325dd08d66ca14926f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:38:53 -0700 Subject: [PATCH 23/28] chore(release): map chanyoung.kim@nota.ai -> channkim for #47049 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 6f56a14154d5..b2f5f7d8ddc4 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -102,6 +102,7 @@ "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "chanyoung.kim@nota.ai": "channkim", "stevenn.damatoo@gmail.com": "x1erra", "evansrory@gmail.com": "zimigit2020", "237263164+ft-ioxcs@users.noreply.github.com": "ft-ioxcs", From e48554a3e0d5bec74e619070c3fd3f03cac52716 Mon Sep 17 00:00:00 2001 From: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:55:50 -0700 Subject: [PATCH 24/28] feat(cli): lock hermes worktrees so concurrent processes can't clobber them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git worktree lock at creation and unlock before removal. A locked worktree refuses 'git worktree remove' (and prune), so a second hermes process or a stray cleanup can't silently delete an in-use isolated worktree. Fail-soft on both paths — a lock/unlock error never blocks the session or cleanup. Salvaged from #47029 (Issue #46303). Unlock moved to the actual-removal path so a preserved (unpushed-commits) worktree stays locked while in use. --- cli.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cli.py b/cli.py index 4e4ddb015c0f..f6a9393d34a5 100644 --- a/cli.py +++ b/cli.py @@ -1340,6 +1340,17 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]: except Exception as e: logger.debug("Error copying .worktreeinclude entries: %s", e) + # Lock the worktree so other processes (and `git worktree remove`) can see + # it is actively in use. Fail-soft: a lock failure never blocks the session. + try: + subprocess.run( + ["git", "worktree", "lock", "--reason", f"hermes pid={os.getpid()}", str(wt_path)], + capture_output=True, text=True, timeout=10, cwd=repo_root, + ) + logger.debug("Worktree locked: %s (pid=%s)", wt_path, os.getpid()) + except Exception as e: + logger.debug("git worktree lock failed (non-fatal): %s", e) + info = { "path": str(wt_path), "branch": branch_name, @@ -1415,6 +1426,16 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None: # Remove worktree (even if working tree is dirty — uncommitted # changes without unpushed commits are just artifacts) + # Unlock first so `git worktree remove` isn't blocked by the lock we + # placed at creation time. Fail-soft — never block cleanup. + try: + subprocess.run( + ["git", "worktree", "unlock", wt_path], + capture_output=True, text=True, timeout=10, cwd=repo_root, + ) + except Exception as e: + logger.debug("git worktree unlock failed (non-fatal): %s", e) + try: subprocess.run( ["git", "worktree", "remove", wt_path, "--force"], From 8568988b0157dc744f0e0cfa46f7bd770d98aa89 Mon Sep 17 00:00:00 2001 From: teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:56:25 -0700 Subject: [PATCH 25/28] chore: add JoaoMarcos44 to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index b2f5f7d8ddc4..cee08fab0afe 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -46,6 +46,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { "victor@rocketfueldev.com": "victor-kyriazakos", + "87440198+JoaoMarcos44@users.noreply.github.com": "JoaoMarcos44", "286497132+srojk34@users.noreply.github.com": "srojk34", "59806492+sitkarev@users.noreply.github.com": "sitkarev", "zheng@omegasys.eu": "omegazheng", From d06104a9ee163e6369d3870f092de875b2f2ab0c Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 07:50:52 +0530 Subject: [PATCH 26/28] fix(dashboard): resolve chat TUI argv off event loop (#48561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): resolve chat TUI argv off event loop Dashboard chat now resolves its TUI launch command off the FastAPI/WebSocket event loop. The resolver can run `npm install` / `npm run build` through `_make_tui_argv()`, and doing that synchronously in `/api/pty` can block proxy keepalives and other dashboard WebSocket work long enough for reverse-proxy deployments to drop the chat connection. This keeps the current TUI build policy intact: normal production launches still run the correctness-first `npm run build` path, while `HERMES_TUI_DIR` remains the prebuilt/no-build path for distros and containers. The change only moves the potentially slow resolver work to a worker thread for the dashboard chat path, serialized by an `asyncio.Lock` so concurrent chat tabs preserve one-build-at-a-time behavior. `SystemExit` (node/npm missing) and the profile `HTTPException` path still propagate cleanly through `asyncio.to_thread()`. Salvaged from #26124 — rebased onto current main. The async wrapper now threads the `profile` parameter that `_resolve_chat_argv` gained on main since the PR was opened, so cross-profile chat is preserved. Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> * chore: add 0xdany to AUTHOR_MAP * fix(dashboard): bind chat-argv lock to app.state; cover error propagation Self-review hardening on top of the salvaged fix: - Move `_chat_argv_lock` from a module-level `asyncio.Lock()` onto `app.state` (initialised in `_lifespan`, lazy fallback via `_get_chat_argv_lock`), mirroring `event_lock`. A module-level `asyncio.Lock()` binds to whatever event loop is active at import time, which is the exact pattern `_get_event_state`'s docstring warns against (breaks across TestClient instances / uvicorn reloads). This keeps the lock on the running loop. - Add two tests exercising the real `_resolve_chat_argv_async` → `asyncio.to_thread` → lock → re-raise chain: `SystemExit` (node/npm missing) and `HTTPException` (invalid profile) both propagate out of the worker thread and are caught by `pty_ws`'s existing handlers. The prior tests mocked `asyncio.to_thread` away and never covered this path. * test(dashboard): dedupe pty error-propagation tests; assert close code simplify-code cleanup pass on the salvage stack: - Extract the shared scaffolding of the two pty_ws error-propagation tests into `_assert_pty_propagates`, keeping the two tests as distinct contracts for the `except SystemExit` and `except HTTPException` arms. - Assert the stable WebSocket close code (1011) instead of relying solely on the user-facing "Chat unavailable" notice wording — a behavior contract per the AGENTS.md "behavior contracts over snapshots" rule, robust to notice rewording. The detail substring ("unknown profile") is still checked for the HTTPException case since proving the detail survives the thread hop is the point of that test. No production-code change; the helper exercises the same real _resolve_chat_argv_async -> asyncio.to_thread -> lock -> re-raise chain. --------- Co-authored-by: draihan --- hermes_cli/web_server.py | 48 ++++++++++++- scripts/release.py | 1 + tests/hermes_cli/test_web_server.py | 102 ++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 9a6f28a68b50..fb96f0f4b49e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -147,6 +147,11 @@ def _start_desktop_cron_ticker(stop_event: "threading.Event", interval: int = 60 async def _lifespan(app: "FastAPI"): app.state.event_channels = {} # dict[str, set] app.state.event_lock = asyncio.Lock() + # Serializes chat-argv resolution so concurrent /api/pty connections + # don't trigger overlapping ``npm install`` / ``npm run build`` work. + # On app.state (not a module global) so the Lock binds to the running + # event loop during lifespan startup — see _get_event_state's docstring. + app.state.chat_argv_lock = asyncio.Lock() # Desktop-spawned backends (HERMES_DESKTOP=1) fire cron jobs themselves, # since the app has no gateway running the scheduler. Server `hermes @@ -187,6 +192,20 @@ def _get_event_state(app: "FastAPI"): return app.state.event_channels, app.state.event_lock +def _get_chat_argv_lock(app: "FastAPI") -> asyncio.Lock: + """Return the chat-argv resolution lock from app.state. + + Mirrors :func:`_get_event_state`: prefers the lifespan-initialised Lock + (created on the correct event loop) but lazily initialises it for + non-``with`` TestClient usages. + """ + try: + return app.state.chat_argv_lock + except AttributeError: + app.state.chat_argv_lock = asyncio.Lock() + return app.state.chat_argv_lock + + app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan) # --------------------------------------------------------------------------- @@ -10745,7 +10764,8 @@ def _ws_auth_ok(ws: "WebSocket") -> bool: # and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id # the chat tab generates on mount; entries auto-evict when the last subscriber # drops AND the publisher has disconnected. -# (State is initialised in _lifespan on app startup — see above.) +# (Channel state and the chat-argv lock are initialised in _lifespan on app +# startup — see _get_event_state / _get_chat_argv_lock above.) def _resolve_chat_argv( @@ -10862,6 +10882,30 @@ def _build_gateway_ws_url() -> Optional[str]: return f"ws://{netloc}/api/ws?{qs}" +async def _resolve_chat_argv_async( + resume: Optional[str] = None, + sidecar_url: Optional[str] = None, + profile: Optional[str] = None, +) -> tuple[list[str], Optional[str], Optional[dict]]: + """Resolve chat argv without blocking the dashboard event loop. + + ``_resolve_chat_argv`` may run ``npm install`` / ``npm run build`` through + ``_make_tui_argv``. Keep that synchronous work off the WebSocket event + loop so reverse proxies and existing dashboard connections can continue + to exchange keepalives while the TUI launch command is prepared. The + async lock preserves the previous one-build-at-a-time behavior when + multiple browser tabs connect at once without occupying worker threads + while queued connections wait. + """ + async with _get_chat_argv_lock(app): + return await asyncio.to_thread( + _resolve_chat_argv, + resume=resume, + sidecar_url=sidecar_url, + profile=profile, + ) + + def _build_sidecar_url(channel: str) -> Optional[str]: """ws:// URL the PTY child should publish events to, or None when unbound. @@ -10992,7 +11036,7 @@ async def pty_ws(ws: WebSocket) -> None: sidecar_url = _build_sidecar_url(channel) if channel else None try: - argv, cwd, env = _resolve_chat_argv( + argv, cwd, env = await _resolve_chat_argv_async( resume=resume, sidecar_url=sidecar_url, profile=profile ) except HTTPException as exc: diff --git a/scripts/release.py b/scripts/release.py index cee08fab0afe..6c5d33ec3a1e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -208,6 +208,7 @@ "me@promplate.dev": "CNSeniorious000", "yichengqiao21@gmail.com": "YarrowQiao", "erhanyasarx@gmail.com": "erhnysr", + "draihan@student.ubc.ca": "0xdany", # PR #26124 salvage (chat argv off event loop) "30366221+WorldWriter@users.noreply.github.com": "WorldWriter", "dafeng@DafengdeMacBook-Pro.local": "WorldWriter", "schepers.zander1@gmail.com": "Strontvod", diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index f03265ee6788..e0ad77dfc8ad 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1,5 +1,6 @@ """Tests for hermes_cli.web_server and related config utilities.""" +import asyncio import os import json import shutil @@ -5132,6 +5133,107 @@ def test_rejects_bad_token(self, monkeypatch): pass assert exc.value.code == 4401 + def test_resolve_chat_argv_async_uses_worker_thread(self, monkeypatch): + captured: dict = {} + + def fake_resolve(resume=None, sidecar_url=None, profile=None): + captured["resume"] = resume + captured["sidecar_url"] = sidecar_url + captured["profile"] = profile + return (["node", "dist/entry.js"], "/tmp/ui-tui", {"NODE_ENV": "production"}) + + async def fake_to_thread(fn, *args, **kwargs): + captured["thread_fn"] = fn + captured["thread_args"] = args + captured["thread_kwargs"] = kwargs + return fn(*args, **kwargs) + + monkeypatch.setattr(self.ws_module, "_resolve_chat_argv", fake_resolve) + monkeypatch.setattr(self.ws_module.asyncio, "to_thread", fake_to_thread) + + argv, cwd, env = asyncio.run( + self.ws_module._resolve_chat_argv_async( + resume="sess-42", + sidecar_url="ws://127.0.0.1:9119/api/pub?channel=abc", + profile="worker", + ) + ) + + assert callable(captured["thread_fn"]) + assert captured["thread_args"] == () + assert captured["thread_kwargs"] == { + "resume": "sess-42", + "sidecar_url": "ws://127.0.0.1:9119/api/pub?channel=abc", + "profile": "worker", + } + assert argv == ["node", "dist/entry.js"] + assert cwd == "/tmp/ui-tui" + assert env == {"NODE_ENV": "production"} + assert captured["resume"] == "sess-42" + assert captured["sidecar_url"] == "ws://127.0.0.1:9119/api/pub?channel=abc" + assert captured["profile"] == "worker" + + def test_pty_ws_resolves_argv_through_async_wrapper(self, monkeypatch): + captured: dict = {} + + async def fake_resolve_async(resume=None, sidecar_url=None, profile=None): + captured["resume"] = resume + captured["sidecar_url"] = sidecar_url + captured["profile"] = profile + return (["/bin/sh", "-c", "printf async-resolve-ok"], None, None) + + monkeypatch.setattr(self.ws_module, "_resolve_chat_argv_async", fake_resolve_async) + + with self.client.websocket_connect(self._url(resume="sess-99")) as conn: + try: + conn.receive_bytes() + except Exception: + pass + + assert captured["resume"] == "sess-99" + + def _assert_pty_propagates(self, monkeypatch, raising_resolver, *, profile=None, expect_detail=None): + """Drive /api/pty with a resolver that raises, and assert the error + propagates through the real _resolve_chat_argv_async -> asyncio.to_thread + -> lock -> re-raise chain into pty_ws's handler: the "Chat unavailable" + notice is sent and the socket closes with code 1011 (the stable + contract — we assert the close code, not the exact notice wording).""" + from starlette.websockets import WebSocketDisconnect + + # Patch the REAL resolver so the whole wrapper/to_thread/lock chain runs. + monkeypatch.setattr(self.ws_module, "_resolve_chat_argv", raising_resolver) + + url = self._url(profile=profile) if profile else self._url() + with self.client.websocket_connect(url) as conn: + notice = conn.receive_text() + with pytest.raises(WebSocketDisconnect) as exc: + conn.receive_text() + assert "Chat unavailable" in notice + assert exc.value.code == 1011 + if expect_detail is not None: + assert expect_detail in notice + + def test_pty_ws_propagates_systemexit_through_async_wrapper(self, monkeypatch): + """SystemExit from _make_tui_argv (node/npm missing) propagates through + the async wrapper and is caught by pty_ws's ``except SystemExit``.""" + + def boom(resume=None, sidecar_url=None, profile=None): + raise SystemExit("node not found") + + self._assert_pty_propagates(monkeypatch, boom) + + def test_pty_ws_propagates_httpexception_through_async_wrapper(self, monkeypatch): + """An invalid-profile HTTPException raised inside the threaded resolver + propagates through the wrapper and hits pty_ws's ``except HTTPException``.""" + from fastapi import HTTPException + + def bad_profile(resume=None, sidecar_url=None, profile=None): + raise HTTPException(status_code=404, detail="unknown profile") + + self._assert_pty_propagates( + monkeypatch, bad_profile, profile="ghost", expect_detail="unknown profile" + ) + def test_streams_child_stdout_to_client(self, monkeypatch): monkeypatch.setattr( self.ws_module, From 637c67455993f0c90d31522629e5560f7032a68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B3=AF=E5=B2=B8=E3=80=80=E4=BA=AE?= <1920071390@campus.ouj.ac.jp> Date: Fri, 19 Jun 2026 11:22:06 +0900 Subject: [PATCH 27/28] Fix CI dependency and Windows README contracts --- README.md | 4 ++++ tools/lazy_deps.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 61c93419103a..1e06b0d87fd3 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,10 @@ This fork follows the same core engineering constraints as upstream Hermes: ## Quick Start +On Windows, the supported bootstrap path is the PowerShell installer in +`scripts/install.ps1`. Clone-based development is still available when you want +to work directly from source. + ```powershell git clone https://github.com/zapabob/hermes-agent.git cd hermes-agent diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index c8b7605448d5..60358a83c9f9 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -182,8 +182,8 @@ "tool.dashboard": ( "fastapi==0.133.1", "uvicorn[standard]==0.41.0", - "starlette==1.0.1", # CVE-2026-48710 (BadHost) — keep lazy-install in sync with pyproject [web] - "python-multipart==0.0.20", # FastAPI UploadFile/Form for streaming uploads (NS-501) + "starlette==1.3.1", # CVE-2026-48710 (BadHost) — keep lazy-install in sync with pyproject [web] + "python-multipart>=0.0.32,<1", # FastAPI UploadFile/Form for streaming uploads (NS-501) ), # Vision image-resize recovery (Pillow). Pillow is now a CORE dependency # (pyproject `dependencies`), so this entry is a belt-and-suspenders fallback From 96cf63b384747545f470dd68aa1d9510202773dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B3=AF=E5=B2=B8=E3=80=80=E4=BA=AE?= <1920071390@campus.ouj.ac.jp> Date: Fri, 19 Jun 2026 12:05:12 +0900 Subject: [PATCH 28/28] Split install-hook packaging change out of PR --- setup.py | 59 --------------- tests/test_docker_webui_install_surface.py | 87 ---------------------- 2 files changed, 146 deletions(-) delete mode 100644 tests/test_docker_webui_install_surface.py diff --git a/setup.py b/setup.py index 6e3e8c4272e8..8487f76e86f8 100644 --- a/setup.py +++ b/setup.py @@ -2,68 +2,13 @@ from collections import defaultdict from pathlib import Path -import tempfile from setuptools import setup -from setuptools.command.build import build as _build -from setuptools.command.egg_info import egg_info as _egg_info REPO_ROOT = Path(__file__).parent.resolve() -def _source_tree_is_writable() -> bool: - probe = REPO_ROOT / ".setuptools-write-probe" - try: - with probe.open("w", encoding="utf-8") as handle: - handle.write("") - probe.unlink() - except OSError: - try: - probe.unlink(missing_ok=True) - except OSError: - pass - return False - return True - - -def _temporary_build_dir(kind: str) -> str: - return tempfile.mkdtemp(prefix=f"hermes-agent-{kind}-") - - -def _would_write_under_source(path_value: str | None) -> bool: - if path_value is None: - return True - path = Path(path_value) - if not path.is_absolute(): - path = REPO_ROOT / path - try: - path.resolve().relative_to(REPO_ROOT) - except ValueError: - return False - return True - - -class ReadOnlySourceBuild(_build): - def finalize_options(self) -> None: - if ( - not _source_tree_is_writable() - and _would_write_under_source(self.build_base) - ): - self.build_base = _temporary_build_dir("build") - super().finalize_options() - - -class ReadOnlySourceEggInfo(_egg_info): - def finalize_options(self) -> None: - if ( - not _source_tree_is_writable() - and _would_write_under_source(self.egg_base) - ): - self.egg_base = _temporary_build_dir("egg-info") - super().finalize_options() - - def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]: root = REPO_ROOT / root_name grouped: defaultdict[str, list[str]] = defaultdict(list) @@ -76,10 +21,6 @@ def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]: setup( - cmdclass={ - "build": ReadOnlySourceBuild, - "egg_info": ReadOnlySourceEggInfo, - }, data_files=[ *_data_file_tree("skills"), *_data_file_tree("optional-skills"), diff --git a/tests/test_docker_webui_install_surface.py b/tests/test_docker_webui_install_surface.py deleted file mode 100644 index 413bfdaf0718..000000000000 --- a/tests/test_docker_webui_install_surface.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Guards for the multi-container Hermes WebUI install surface.""" - -from __future__ import annotations - -from pathlib import Path -import runpy - -from setuptools import Distribution -import setuptools - - -REPO_ROOT = Path(__file__).resolve().parent.parent - - -def _is_under(path: str, root: Path) -> bool: - try: - Path(path).resolve().relative_to(root.resolve()) - except ValueError: - return False - return True - - -def test_docker_context_includes_license_file() -> None: - """PEP 639 license-files metadata must resolve inside the Docker image.""" - dockerignore = (REPO_ROOT / ".dockerignore").read_text(encoding="utf-8") - active_lines = [ - line.strip() - for line in dockerignore.splitlines() - if line.strip() and not line.lstrip().startswith("#") - ] - - assert "LICENSE" not in active_lines - - -def test_setup_uses_temporary_outputs_when_source_tree_is_read_only( - monkeypatch, -) -> None: - """WebUI installs from read-only /opt/hermes must not write build metadata.""" - captured: dict[str, object] = {} - - def capture_setup(**kwargs: object) -> None: - captured.update(kwargs) - - monkeypatch.setattr(setuptools, "setup", capture_setup) - namespace = runpy.run_path(str(REPO_ROOT / "setup.py")) - - cmdclass = captured["cmdclass"] - monkeypatch.setitem( - cmdclass["build"].finalize_options.__globals__, - "_source_tree_is_writable", - lambda: False, - ) - monkeypatch.setitem( - cmdclass["egg_info"].finalize_options.__globals__, - "_source_tree_is_writable", - lambda: False, - ) - - build_cmd = cmdclass["build"](Distribution()) - build_cmd.initialize_options() - build_cmd.finalize_options() - assert not _is_under(build_cmd.build_base, REPO_ROOT) - assert Path(build_cmd.build_base).name.startswith("hermes-agent-build") - - source_relative_build = cmdclass["build"](Distribution()) - source_relative_build.initialize_options() - source_relative_build.build_base = "nested/build" - source_relative_build.finalize_options() - assert not _is_under(source_relative_build.build_base, REPO_ROOT) - assert Path(source_relative_build.build_base).name.startswith("hermes-agent-build") - - egg_info_cmd = cmdclass["egg_info"](Distribution()) - egg_info_cmd.initialize_options() - egg_info_cmd.finalize_options() - assert egg_info_cmd.egg_base is not None - assert not _is_under(egg_info_cmd.egg_base, REPO_ROOT) - assert Path(egg_info_cmd.egg_base).name.startswith("hermes-agent-egg-info") - - source_relative_egg_info = cmdclass["egg_info"](Distribution()) - source_relative_egg_info.initialize_options() - source_relative_egg_info.egg_base = "." - source_relative_egg_info.finalize_options() - assert source_relative_egg_info.egg_base is not None - assert not _is_under(source_relative_egg_info.egg_base, REPO_ROOT) - assert Path(source_relative_egg_info.egg_base).name.startswith( - "hermes-agent-egg-info" - )